The Status Subresource in Kubernetes Controllers

September 22, 2025

The Status Subresource in Kubernetes Controllers
The Status Subresource in Kubernetes Controllers

Spec is what the user wants. Status is what the controller observes. Keeping them separate is how you avoid infinite reconcile loops.

Estimated Reading Time : 7m

Spec vs status

Every Kubernetes resource follows the same pattern:

  • Spec — the desired state, written by the user
  • Status — the observed state, written by the controller

A Deployment’s spec says “I want 3 replicas.” Its status says “3 replicas are available, 0 are unavailable.” The controller’s job is to make status converge toward spec.

The problem without a status subresource

Without the status subresource enabled, spec and status are updated through the same API endpoint. When your controller calls r.Update() to write status, it also writes back the spec — and the update increments .metadata.generation, which triggers another reconcile.

The result is a reconcile loop: reconcile → update status → generation changes → reconcile → update status → repeat.

Enabling the status subresource

Add the +kubebuilder:subresource:status marker to your CRD type:

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status

type Backup struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   BackupSpec   `json:"spec,omitempty"`
    Status BackupStatus `json:"status,omitempty"`
}

Regenerate the CRD manifest:

controller-gen crd paths="./api/..." output:crd:dir=config/crd

Now the API server exposes two endpoints for your resource:

  • /apis/backup.example.com/v1alpha1/namespaces/{ns}/backups/{name} — for spec updates
  • /apis/backup.example.com/v1alpha1/namespaces/{ns}/backups/{name}/status — for status updates

Updating status

Use r.Status().Update() instead of r.Update():

func (r *BackupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var backup v1alpha1.Backup
    if err := r.Get(ctx, req.NamespacedName, &backup); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    // Do reconciliation work...

    // Update status
    backup.Status.Phase = "Running"
    backup.Status.LastBackupTime = &metav1.Time{Time: time.Now()}
    if err := r.Status().Update(ctx, &backup); err != nil {
        return ctrl.Result{}, err
    }

    return ctrl.Result{}, nil
}

r.Status().Update() only writes the status fields through the /status subresource endpoint. It does not modify spec or increment generation.

Status().Patch() vs Status().Update()

Update() sends the entire resource and requires an up-to-date resourceVersion. If another process modified the resource between your Get and your Update, you’ll get a conflict error.

Patch() sends only the diff and is less prone to conflicts:

patch := client.MergeFrom(backup.DeepCopy())
backup.Status.Phase = "Completed"
backup.Status.Message = "Backup finished successfully"

if err := r.Status().Patch(ctx, &backup, patch); err != nil {
    return ctrl.Result{}, err
}

Prefer Patch() when possible, especially in controllers where multiple processes might update the same resource.

Conditions

The Kubernetes convention for complex status is to use conditions — a slice of structured status entries:

type BackupStatus struct {
    Conditions []metav1.Condition `json:"conditions,omitempty"`
}
meta.SetStatusCondition(&backup.Status.Conditions, metav1.Condition{
    Type:               "Ready",
    Status:             metav1.ConditionTrue,
    Reason:             "BackupCompleted",
    Message:            "Backup finished successfully",
    LastTransitionTime: metav1.Now(),
})

Conditions let users and other controllers query status in a standard way:

kubectl get backups -o jsonpath='{.items[*].status.conditions[?(@.type=="Ready")].status}'

The pattern

The standard reconcile pattern with status:

func (r *BackupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var backup v1alpha1.Backup
    if err := r.Get(ctx, req.NamespacedName, &backup); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    // Save a copy for patching status later
    original := backup.DeepCopy()

    // Do work, update backup.Status fields...

    // Patch status at the end
    if err := r.Status().Patch(ctx, &backup, client.MergeFrom(original)); err != nil {
        return ctrl.Result{}, err
    }

    return ctrl.Result{}, nil
}

Fetch → work → patch status. The status update doesn’t trigger a new reconcile because it goes through the status subresource.