Kubernetes Status Subresource and Conditions Explained (KEP-1623)

Last reviewed: by
Kubernetes Status Subresource and Conditions Explained (KEP-1623)

A user runs kubectl apply to express desired state on .spec. The controller runs Reconcile() and discovers what the actual state of the world is. The status subresource is how the controller publishes that discovery back to the API so users, CI/CD pipelines, peer controllers, and kubectl wait know what is going on. Get this surface right and your Operator integrates cleanly with every other tool in the Kubernetes ecosystem; get it wrong and users have no idea whether the resource is ready, failing, or still in flight.

This guide covers why the status subresource exists separately from .spec, the KEP-1623 Conditions standard, the meta.SetStatusCondition helper, the observedGeneration field, and the reconcile hot-loop trap that catches almost every first-time Operator author writing status updates.

The split-URL design: /spec is the user's lane, /status is the controller's lane. The conditions array is the structured language they use to communicate.

If you have not yet read desired state vs actual state and the reconcile loop explained, those two articles set up why the split exists; this one is how to use it cleanly.


TL;DR — status & conditions in 60 seconds

The status subresource is a separate URL the API server exposes for each custom resource:

Endpoint Who writes What lives there
/apis/<g>/<v>/.../<plural>/<name> Users via kubectl apply metadata, .spec
/apis/<g>/<v>/.../<plural>/<name>/status Controllers via r.Status().Update() .status only

A Condition is a structured entry in .status.conditions describing one observable property of the resource. Every controller in the ecosystem now uses the same shape, standardised by KEP-1623:

yaml
status:
  observedGeneration: 7
  conditions:
  - type: Ready
    status: "True"
    reason: ReconcileSucceeded
    message: "All backup targets are healthy"
    lastTransitionTime: "2026-05-31T10:31:12Z"
    observedGeneration: 7

Status subresource enabled? Verify with kubectl get crd <plural>.<group> -o jsonpath='{.spec.versions[0].subresources}'

  • the output should contain "status":{}. Without it, r.Status().Update() writes through to .spec and you get cross-lane races. See Custom Resource Definitions explained for how to declare the subresource in the CRD YAML.

A quick analogy: an Amazon order page

Picture an Amazon order page.

  • What you ordered"1 pair of running shoes, size 10, deliver by Tuesday" — is your .spec. You wrote it. You can change it (with caveats) up until shipment.
  • What you see on the order page — "Order placed → packed → shipped → out for delivery → delivered" — is your .status. Amazon writes it. You never write to that field — and if you somehow could, you would trample over Amazon's tracker.
  • The little status tags "Packed", "Out for delivery", "Delivered" — each with a timestamp and a short explanation — are Conditions.

E-commerce order tracker analogy for Kubernetes status subresource and Conditions. A phone shows two panels: .spec — what YOU ordered, written by user (1 × Running shoes, size 10, deliver by Tuesday) and .status — what AMAZON says, written by the system (a progress tracker: Order Placed → Packed → Shipped → Out for Delivery → Delivered, each step tagged with a condition like Ready=True, Packed=True, Shipped=True, Delivered=Unknown). Caption: each tag = one Condition (type, status, reason, message, lastTransitionTime).

Now imagine if Amazon let you accidentally edit "Delivered: True" on a package that was still in the warehouse. Chaos. That is exactly why Kubernetes split /spec and /status into two URLs — the user writes one, the controller writes the other, and they cannot accidentally overwrite each other's lanes.

The KEP-1623 condition standard is what makes the status tags consistent across every Operator in the world, the same way every shipping company ended up with the same vocabulary: Packed, In transit, Out for delivery, Delivered. Once you know the words, every package — and every custom resource — speaks the same language.


Why status and conditions matter

Three concrete reasons it is worth getting this surface right, rather than treating .status as a free-form scratch area:

1. It is the contract every ecosystem tool expects

kubectl wait --for=condition=Ready, Argo CD, Flux, Helmfile, Backstage, the Operator dashboard in the OpenShift console — they all read the .status.conditions array and they all expect the KEP-1623 polarity-positive types (Ready=True is the good state). The moment you publish a non-standard Phase enum or invert the polarity ("NotReady=False"), every downstream tool stops working — silently, because they will not crash, they will simply wait forever for a condition that never appears.

2. It is the only thing peer controllers can react to

Operators chain on each other all the time — a CertificateRequest controller waits for a Backup controller, which waits for a PostgresCluster controller. The only public surface they share is each other's .status. If your status is unreliable, every operator that depends on you is unreliable too. Different operator design patterns (Singleton, Lifecycle, Auto-Pilot) demand different status shapes, but they all live or die on the same contract.

3. observedGeneration is how CI/CD knows your change landed

Every GitOps tool watches for observedGeneration == metadata.generation before it considers a rollout complete. Omit it once and your CI pipelines will start acting on stale conditions — marking deployments green minutes before the controller has actually caught up to the new .spec. This is the single most common cause of "my pipeline passed but production is broken" on Operator-managed workloads.


The KEP-1623 Conditions standard

Before KEP-1623 every project invented its own status schema - some used a phase: Pending|Running|Failed enum, some used a free-form message, some nested conditions inside conditions. The fragmentation broke ecosystem tools like kubectl wait, GitOps controllers, and Operator dashboards.

KEP-1623 standardised the shape every condition must take. The metav1.Condition struct is defined in k8s.io/apimachinery/pkg/apis/meta/v1:

go
type Condition struct {
    Type               string             // PascalCase, "Ready", "Progressing"
    Status             ConditionStatus    // "True" | "False" | "Unknown"
    ObservedGeneration int64              // metadata.generation when written
    LastTransitionTime metav1.Time        // when Status last flipped
    Reason             string             // machine-readable, no spaces
    Message            string             // human-readable
}

Three rules from the KEP that catch every Operator author:

  • Polarity-positive types only. Ready=True is the good state. NotReady is not a valid type - use Ready=False. The convention avoids double-negatives in dashboards (NotReady=False is unreadable).
  • Reason must be a CamelCase token, not a sentence. Good: ReconcileSucceeded, EndpointNotResolvable. Bad: reconcile succeeded.
  • LastTransitionTime updates only when Status flips. Re-reconciles that confirm the same state must not bump the timestamp - otherwise every dashboard shows constantly-changing state.

meta.SetStatusCondition (in k8s.io/apimachinery/pkg/api/meta) implements these rules correctly and is the only sensible way to append-or-update a condition:

go
import (
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/api/meta"
)

meta.SetStatusCondition(&backup.Status.Conditions, metav1.Condition{
    Type:               "Ready",
    Status:             metav1.ConditionTrue,
    ObservedGeneration: backup.Generation,
    Reason:             "ReconcileSucceeded",
    Message:            "All backup targets are healthy",
})

The helper handles three things you do not want to write yourself:

  1. If a condition of the same Type already exists, it is updated in place (not appended again).
  2. LastTransitionTime is bumped only when Status actually flips.
  3. The output array is kept sorted by Type for stable diffs in kubectl get -o yaml.

The four standard condition types

KEP-1623 does not mandate a fixed list, but the ecosystem has converged on four polarity-positive types that you should publish in almost every Operator:

Type True means Example Reason codes
Ready The resource is fulfilling its purpose right now. ReconcileSucceeded when True; ReconcileError, BackupPolicyMisconfigured, WaitingForDependency when False
Progressing The controller is actively reconciling toward the desired state. False simply means idle, not broken. Reconciling when True; RolloutComplete when False
Available The resource has at least the minimum capacity to serve users (e.g. enough replicas). MinimumReplicasAvailable when True; MinimumReplicasUnavailable, EndpointNotResolvable when False
Degraded Something is wrong, but the resource is still functioning - partial outage, not full failure. OneOfThreeReplicasUnhealthy, SlowResponseTime when True (note: True is the bad state here)

Degraded is the only condition where True is the bad state, kept that way for backwards compatibility with pre-KEP usage. Many newer Operators omit it; if you do publish it, document the polarity explicitly.

Not every resource needs every condition. A simple ConfigMap-like custom resource may only need Ready. A controller managing a multi-node service may publish all four. Pick the minimum that gives users actionable information.

Lifecycle of Kubernetes Conditions showing how a resource moves through Progressing, Available, Ready, and Degraded states during reconciliation and recovery

The diagram below shows a typical lifecycle for an Operator-managed resource. Most resources start with Progressing=True while reconciliation is in progress, eventually become Available=True and Ready=True, and may later enter Degraded=True if the controller detects a problem. After recovery, the resource transitions back through Progressing and returns to Ready=True.


observedGeneration - the "is this status fresh?" field

metadata.generation is a 64-bit integer the API server increments every time .spec is mutated. Status updates do not bump generation - it is specifically the user-mutation counter.

observedGeneration is your controller's promise: "I have seen generation = N and the conditions below describe that version of the spec." When a user runs:

bash
kubectl wait <kind> <name> --for=condition=Ready

kubectl checks both Status=True and observedGeneration == metadata.generation. If a stale status from before the latest user edit still says Ready=True, kubectl wait does the right thing and keeps waiting until the controller catches up.

In practice you set it on every condition you write:

go
meta.SetStatusCondition(&backup.Status.Conditions, metav1.Condition{
    Type:               "Ready",
    Status:             metav1.ConditionTrue,
    ObservedGeneration: backup.Generation,
    Reason:             "ReconcileSucceeded",
    Message:            "All targets healthy",
})

Some Operators also publish a top-level status.observedGeneration for the whole resource - this is what newer KEP-1623 audits prefer. Either pattern is acceptable as long as you pick one and stick to it. Operators that also do periodic drift detection typically expose both: observedGeneration for "did I see the latest spec?" and a separate lastSyncTime for "when did I last verify external state matches?".


How kubectl wait uses conditions

The most common consumer of .status.conditions is kubectl wait — used by CI/CD pipelines, end-to-end tests, and kubectl rollout-style scripts to block until a custom resource is ready before the next step runs.

bash
# Wait up to 5 minutes for the backup to be Ready=True
kubectl wait backup nightly --for=condition=Ready=True --timeout=300s

# Wait for Progressing to drop back to False (rollout complete)
kubectl wait postgrescluster prod --for=condition=Progressing=False

What kubectl wait --for=condition=<Type>[=<Status>] actually checks:

  1. The resource has a condition of the given Type in .status.conditions.
  2. The condition's Status matches the requested value (defaults to True).
  3. observedGeneration == metadata.generation — the condition was written by the controller after the most recent .spec change.

Step 3 is why setting ObservedGeneration on every condition write matters. Without it, a stale Ready=True from before the user's latest kubectl apply would make kubectl wait return immediately — and your CI would mark the rollout green before the controller has actually caught up.

Two patterns peer controllers and pipelines also use:

  • JSONPath probekubectl get <kind> <name> -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' returns the literal "True" / "False" / "Unknown". Useful in shell pipelines that cannot afford the kubectl wait watch loop.
  • Full condition objectkubectl get <kind> <name> -o json | jq '.status.conditions[] | select(.type=="Ready")' includes reason and message, the easiest way to surface "why is it not ready?" in CI logs.

For consumers that do not want a separate kubectl wait step, your CRD's additionalPrinterColumns can surface the same conditions inline:

yaml
additionalPrinterColumns:
- name: Ready
  type: string
  jsonPath: .status.conditions[?(@.type=="Ready")].status
- name: Reason
  type: string
  jsonPath: .status.conditions[?(@.type=="Ready")].reason
- name: Age
  type: date
  jsonPath: .metadata.creationTimestamp

Now kubectl get backup shows Ready / Reason / Age columns by default, with no -o flag required — and pipelines can grep '^backup-name.*True' as a quick readiness probe without parsing JSON.


Why split spec and status into two URLs?

The split solves three concrete problems that the original single-URL design suffered from in 2017-2018:

  1. Optimistic-concurrency clashes. When a user edited .spec and the controller updated .status at almost the same instant, one of the two Update calls failed with a 409 Conflict because both based their write on the same resourceVersion. With the split, the two writes never touch the same field group.
  2. RBAC granularity. A user role can have get/list/update on a custom resource without being able to write .status - the controller's ServiceAccount is the only thing with update on the /status URL. This stops a curious user from manually marking a resource Ready=True. The per-subresource verbs (.../status) are part of why least-privilege Operator RBAC is easy to enforce in the first place.
  3. Cleaner schema validation. The CRD schema's subresources.status: {} declaration lets the API server fast-reject any request that tries to mutate the wrong half of the object.

Inside your controller, the two writes are one method call apart:

go
r.Update(ctx, &obj)            // writes .metadata and .spec
r.Status().Update(ctx, &obj)   // writes .status only

A common newcomer bug is to call r.Update after setting conditions in .status - the call succeeds but .status does not change, because the non-/status endpoint silently drops status fields when the subresource is enabled. Always use Status().Update() for status writes.


What belongs in .status (and what doesn't)

Newcomers routinely shove the wrong things into .status — desired configuration, secrets, debug logs, free-form notes — and then wonder why their operator becomes hard to reason about. The rule of thumb is simple: .status is only for facts you have observed about the world and could re-derive from the cluster if you restarted.

Belongs in .status Does not belong in .status
Observed external IDs (cloud resource ARNs, DB UUIDs) Secrets, credentials, tokens
Observed counts (readyReplicas, usedBytes, currentVersion) User input — that belongs in .spec
References to created child resources you own Configuration that drives behaviour (also .spec)
Conditions (KEP-1623) — Ready, Progressing, Available, Degraded A scratch field per reconcile (use the controller's in-memory cache)
observedGeneration for freshness tracking Last-reconcile wall-clock (lastTransitionTime on a condition is the canonical place)
A phase enum for human-readable summary (in addition to conditions) Anything you would have to guess if the controller restarted

Three corollaries that follow from the rule:

  • If a value cannot be re-derived from the cluster after a controller restart, it does not belong in .status — store it on an annotation, a child resource, or an external system.
  • Anything secret belongs in a Secret, not in .status — status is world-readable to anyone with get on the parent resource.
  • A phase enum can coexist with conditions, but Ready / Progressing / Available / Degraded are still required because that is what kubectl wait --for=condition=... and the rest of the ecosystem reads.

The full status-update pattern

A complete reconciler that handles spec, status, and the hot-loop trap:

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

    if !backup.GetDeletionTimestamp().IsZero() {
        return r.reconcileDelete(ctx, &backup)
    }

    if err := r.ensureBackupTarget(ctx, &backup); err != nil {
        return r.updateStatus(ctx, &backup, metav1.Condition{
            Type:    "Ready",
            Status:  metav1.ConditionFalse,
            Reason:  "EnsureTargetFailed",
            Message: err.Error(),
        })
    }

    return r.updateStatus(ctx, &backup, metav1.Condition{
        Type:    "Ready",
        Status:  metav1.ConditionTrue,
        Reason:  "ReconcileSucceeded",
        Message: "Backup target is healthy",
    })
}

func (r *BackupReconciler) updateStatus(ctx context.Context, b *acmev1.Backup, c metav1.Condition) (ctrl.Result, error) {
    c.ObservedGeneration = b.Generation

    original := b.DeepCopy()
    meta.SetStatusCondition(&b.Status.Conditions, c)

    if equality.Semantic.DeepEqual(original.Status, b.Status) {
        return ctrl.Result{}, nil
    }

    if err := r.Status().Update(ctx, b); err != nil {
        return ctrl.Result{}, err
    }
    return ctrl.Result{}, nil
}

The five things to notice:

  1. equality.Semantic.DeepEqual guard before Status().Update(). This is the core hot-loop killer (see the next section). No-op status writes are skipped entirely. The helper lives in k8s.io/apimachinery/pkg/api/equality and is preferred over the stdlib reflect.DeepEqual because it understands metav1.Time rounding, resource.Quantity equivalence, and other Kubernetes-specific semantics that the stdlib version reports as different.
  2. ObservedGeneration is set on every condition write. Cheap and gives every consumer the freshness signal they need.
  3. One helper, one place where status is written. This is much easier to test and review than scattering Status().Update() calls across every error branch.
  4. Errors update status with Ready=False and return the error. The status update succeeds; the requeue from the returned error keeps the controller working on the real fix.
  5. r.Get always returns the freshest cache copy. Do not stash backup across reconciles - the next invocation gets its own object from the cache.

Writing status with Server-Side Apply

For new operators, the recommended way to update status is Server-Side Apply (SSA) rather than Status().Update(). SSA tracks per-field ownership using a field manager, so two controllers that each set a different condition type (e.g. yours sets Ready, cert-manager sets CertificateReady) coexist without clobbering each other on read-modify-write:

go
patch := &unstructured.Unstructured{}
patch.SetGroupVersionKind(acmev1.GroupVersion.WithKind("Backup"))
patch.SetNamespace(backup.Namespace)
patch.SetName(backup.Name)
_ = unstructured.SetNestedSlice(patch.Object, []interface{}{
    map[string]interface{}{
        "type":               "Ready",
        "status":             "True",
        "reason":             "ReconcileSucceeded",
        "message":            "Backup target is healthy",
        "observedGeneration": backup.Generation,
    },
}, "status", "conditions")

return r.Status().Patch(ctx, patch, client.Apply,
    client.FieldOwner("backup-controller"),
    client.ForceOwnership)

See Server-Side Apply (SSA) in operators for the full conflict-resolution rules and when to prefer SSA over Status().Update().


The status-write hot loop (and how to kill it)

Every status update generates a fresh watch event, which triggers another reconcile, which may write status again. Without protection your operator pegs CPU. Two complementary defences:

Defence 1 - GenerationChangedPredicate{} on the primary watch

go
ctrl.NewControllerManagedBy(mgr).
    For(&acmev1.Backup{}).
    WithEventFilter(predicate.GenerationChangedPredicate{}).
    Complete(r)

This predicate only fires Update events when metadata.generation differs - status-only updates do not bump generation, so they are dropped before they reach the workqueue. See watches, events, and predicates for the full predicate catalogue.

Defence 2 - skip the API call when status is unchanged

The equality.Semantic.DeepEqual guard in the pattern above (from k8s.io/apimachinery/pkg/api/equality). Even if a watch event sneaks past the predicate, the API call is skipped when there is nothing new to publish. Combined with meta.SetStatusCondition (which only updates LastTransitionTime on a real flip), this turns the steady-state cost of status maintenance to zero. Avoid the stdlib reflect.DeepEqual here — it will spuriously report two semantically-identical statuses as different whenever metav1.Time rounding or resource.Quantity normalisation comes into play, defeating the guard.

Apply both. They cover slightly different failure modes - the predicate prevents every status-triggered reconcile from running, the deep-equal guard prevents some spec-triggered reconciles from writing status when nothing actually changed.


Status vs Events vs Annotations

Operators have three different surfaces for "telling the world something happened". Newcomers routinely pick the wrong one. The differences are sharper than they look:

Surface Schema Typed Atomic write Survives controller restart Best for
.status CRD-validated Yes Yes (per-update) Yes — lives on the resource Stable, machine-readable observed state that other controllers and kubectl wait consume
Event (corev1.Event) Loose No No (best-effort, deduped) No — TTL ~1 h by default Recent human-readable activity that shows up in kubectl describe
Annotation map[string]string No Yes Yes — lives on the resource Free-form metadata, opt-in feature flags, controller-internal hints

Practical decisions you will make every week:

  • "The backup failed because the target bucket is unreachable." Both — set Ready=False with Reason=TargetUnreachable (machine-readable, the thing peer controllers act on) and fire an Event of type Warning (human-readable, the thing the user sees in kubectl describe backup).
  • "The user wants verbose logging for this one resource." Annotation (acme.io/log-level: debug) — free-form, opt-in, nothing else needs to validate it.
  • "The S3 backup completed at 14:32 and used 1.4 GiB." .status — this is observed state that peer controllers and dashboards need.

Rule of thumb — if a downstream system needs to make a decision on it, put it in .status. If a human needs to read it in kubectl describe, fire an Event. If only your own controller needs to remember it, an annotation is fine.


Status field selectors

When your CRD declares additionalPrinterColumns against .status paths, kubectl uses the same paths for field selectors:

bash
kubectl get backup --field-selector status.phase=Failed -A

The trick: status fields are only field-selectable if you list them in the spec.versions[*].additionalPrinterColumns of the CRD. This is one of the under-appreciated wins of putting real data in status - it becomes queryable without users needing jq.

For consumers that just want a Boolean signal:

bash
kubectl wait backup nightly --for=condition=Ready=True --timeout=300s

kubectl wait checks both Status and observedGeneration, so it is the right tool for CI/CD pipelines and end-to-end tests.


The five anti-patterns that ship to production

  1. Calling r.Update() to write status. The non-/status endpoint drops status changes silently when the subresource is enabled. Fix: always r.Status().Update() for status writes.

  2. Writing status on every reconcile without a guard. Hot loop within seconds. Fix: an equality.Semantic.DeepEqual guard (from k8s.io/apimachinery/pkg/api/equality) before the API call. Prefer it over the stdlib reflect.DeepEqual — it knows about metav1.Time rounding and resource.Quantity equivalence that the stdlib version reports as different.

  3. Not setting ObservedGeneration. kubectl wait and GitOps tools cannot tell stale conditions from fresh ones. Fix: set it on every condition you write.

  4. Inventing a Phase enum instead of using conditions. Pre-KEP pattern; breaks kubectl wait --for=condition=.... Fix: publish Ready, Progressing, Available, Degraded even if you keep a Phase field for backward compatibility.

  5. Re-appending the same condition every reconcile. Without meta.SetStatusCondition you end up with a growing array of duplicate Ready=True entries. Fix: use the helper - it updates in place by Type.

Status anti-pattern cheat sheet

Symptom Root cause Fix
r.Status().Update() returns 200 but .status is empty in kubectl get -o yaml Called r.Update() (not the Status() sub-client), or CRD missing subresources.status: {} Use r.Status().Update(); verify CRD with kubectl get crd ... -o jsonpath='{.spec.versions[0].subresources}'
Operator pegs CPU after a single reconcile, etcd write rate climbs Status hot loop — every Status().Update() triggers a fresh watch event Add predicate.GenerationChangedPredicate{} in SetupWithManager and an equality.Semantic.DeepEqual guard before the API call
kubectl wait --for=condition=Ready returns instantly on a stale status observedGeneration not set on conditions Set ObservedGeneration: obj.Generation on every condition write
Ready flickers TrueFalse every reconcile Manually appending conditions to the slice instead of using meta.SetStatusCondition Switch to meta.SetStatusCondition — it dedupes by Type and only bumps LastTransitionTime on a real flip
kubectl wait --for=condition=Ready returns timeout for a resource that is clearly healthy Using a custom Phase: Ready enum instead of conditions Publish Ready + Progressing + Available conditions alongside any legacy Phase field
Two controllers fight over .status.conditions, overwriting each other Both use Status().Update() (read-modify-write) on the same object Switch both to Server-Side Apply with distinct field managers — see SSA in operators

Frequently Asked Questions

1. What is the status subresource in Kubernetes?

The status subresource is a second URL the API server exposes for a resource, e.g. /apis///...///status, on which only .status can be updated. Users edit .spec against the regular URL; controllers update .status against the /status URL. The split prevents races where a user's kubectl apply would overwrite an in-flight controller status update or vice versa.

2. What is a Condition in Kubernetes?

A Condition is a structured entry in .status.conditions describing one observable property of the resource, e.g. Ready=True, Progressing=False. Each Condition has a type, status (True / False / Unknown), reason (machine-readable short string), message (human-readable explanation), lastTransitionTime, and observedGeneration. The format is standardised by KEP-1623 across the Kubernetes ecosystem.

3. What are the standard Kubernetes condition types?

KEP-1623 standardises four polarity-positive condition types that most controllers should publish: Ready (the resource is fulfilling its purpose right now), Progressing (the controller is actively moving toward the desired state), Available (the resource has enough capacity to serve users), and Degraded (the controller noticed something wrong but is still functioning). Polarity-positive means True is the good state; the convention avoids double-negative bugs.

4. What is observedGeneration in a Kubernetes condition?

observedGeneration records the value of metadata.generation that the controller saw when it wrote the condition. Users can then tell whether the condition reflects the latest spec or a stale spec from before their edit. kubectl wait --for=condition=Ready and most CI/CD tools check this field to avoid acting on stale conditions.

5. Why is my operator stuck in a reconcile hot loop after a status update?

Status writes generate fresh watch events. Without a GenerationChangedPredicate{} on the controller, every Status().Update() triggers another reconcile, which writes status again, indefinitely. The fix is to either apply the predicate, or only call Status().Update() when the new status genuinely differs from the cached one - use meta.SetStatusCondition (idempotent on no-op updates) plus an equality.Semantic.DeepEqual guard (from k8s.io/apimachinery/pkg/api/equality) before the API call.

6. How do I update status in controller-runtime?

Call r.Status().Update(ctx, &obj) rather than r.Update(ctx, &obj). The Status() sub-client writes to the /status URL and ignores any changes to .spec or .metadata.finalizers. Use meta.SetStatusCondition(&obj.Status.Conditions, metav1.Condition{...}) to append-or-update a single condition without rewriting the entire array.

7. Should I use Status or annotations for observability data?

Use .status for facts about the resource that other controllers and kubectl wait need to act on (Ready, Available, Phase, observed external IDs). Use annotations for free-form metadata that does not need a stable schema or atomic updates. The status subresource is typed, validated by the CRD schema, and atomically published; annotations are an untyped string map.

8. What is the difference between Kubernetes Events and the status subresource?

Events are a short-lived, untyped activity log (corev1.Event objects, TTL ~1 hour by default) - good for human-readable "this happened" messages that show up in kubectl describe. The status subresource is the durable, typed, machine-readable observed state - the place GitOps tools, kubectl wait, and peer controllers look. Rule of thumb - if a downstream system needs to make decisions on it, put it in .status; if it is purely for humans skimming the log, fire an Event with EventRecorder.

9. How do I write status with Server-Side Apply?

Construct an unstructured object containing only the status fields you own, set a stable field manager (e.g. "backup-controller"), and call r.Status().Patch(ctx, obj, client.Apply, client.FieldOwner("backup-controller"), client.ForceOwnership). SSA tracks per-field ownership, so two controllers writing different condition types (e.g. yours sets Ready, cert-manager sets CertificateReady) coexist without clobbering each other. This is the recommended status-update mechanism for new operators.

What's next?

You now know how to publish observed state cleanly. Natural next reads:

Looking for the bigger picture? The Kubernetes Operator tutorial sequences every article in this series in pedagogical order — this article is part of the controller-runtime internals chapter, alongside watches, finalizers, and owner references.

Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with over a decade of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, …

  • Red Hat Certified System Administrator in Red Hat OpenStack
  • Certified Kubernetes Application Developer (CKAD)
  • Red Hat Certified Specialist in Ansible Automation
  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security