Finalizers
Finalizers are the “don’t actually delete me yet” mechanism in Kubernetes. When a delete is requested against an object that has a non-empty
metadata.finalizers[]list, the API server records the user’s intent by settingmetadata.deletionTimestampand returns202 Accepted, but does not actually remove the object from etcd until the finalizer list is empty. Controllers and operators that need to perform external cleanup before an object disappears — deleting a cloud load balancer, detaching a persistent volume, deregistering a workload from an external system — install their own finalizer key on the object at creation time, watch for the deletion timestamp to be set, perform their cleanup, and only then remove their finalizer entry. Once the last finalizer is gone, the API server reaps the object. The Kubernetes documentation states the contract precisely: “Finalizers are namespaced keys that tell Kubernetes to wait until specific conditions are met before it fully deletes resources that are marked for deletion.” (kubernetes.io — Finalizers). Finalizers are the only legitimate way to perform pre-deletion side effects in K8s — and because they are infrastructure, not code, they survive controller restarts, retries, and partitions in exactly the same way control loops do. They are also the leading cause of “why won’t this object delete” confusion in operations: a namespace stuck inTerminating, a PVC that won’t go away, a CR whose deletion hangs for hours — almost always a finalizer that did not (or cannot) clear.
Mental Model
sequenceDiagram participant U as User<br/>(kubectl delete X) participant API as kube-apiserver participant ETCD as etcd participant CTRL as Controller<br/>(owns the finalizer) participant EXT as External system<br/>(cloud, DB, etc.) U->>API: DELETE /apis/.../X API->>ETCD: SET metadata.deletionTimestamp=now() API-->>U: 202 Accepted<br/>(object still visible) Note over API,ETCD: finalizers list non-empty<br/>→ object NOT removed CTRL->>API: WATCH sees deletionTimestamp set CTRL->>EXT: perform cleanup<br/>(delete LB, drop DB, etc.) EXT-->>CTRL: cleanup done CTRL->>API: PATCH metadata.finalizers<br/>(remove our key) API->>ETCD: write updated finalizers Note over API,ETCD: finalizers list now empty<br/>+ deletionTimestamp set API->>ETCD: DELETE object API-->>U: subsequent GET → 404
What this diagram shows. Deletion in Kubernetes is two-phase: the user’s DELETE request is a declaration of intent, recorded in metadata.deletionTimestamp; the actual removal happens later, gated by the finalizer list. Controllers that wish to participate in deletion ordering register a finalizer on the object (at creation time, typically inside their reconciler) and react to the deletionTimestamp becoming non-zero by performing their cleanup and then patching their own key out of the list. The insight to extract is that finalizers are level-triggered in the same sense that controllers are: a controller crashing mid-cleanup is fine — when it restarts, it sees the deletion timestamp again, retries the cleanup, and removes its finalizer when done. There is no special “deletion event” to lose; the persistent deletionTimestamp is the signal. This is why finalizers are robust against the entire menagerie of distributed-system failures, and also why a misbehaving controller leaves objects stuck indefinitely — the API server has no concept of “this finalizer has been waiting too long, give up.”
Mechanical Walk-through
What a finalizer literally is
A finalizer is a string. The object’s metadata.finalizers[] array is a list of strings, each one a key naming a piece of cleanup logic that some controller has promised to perform before the object is reaped. The Kubernetes documentation makes the “not code” property explicit: “Finalizers don’t usually specify the code to execute. Instead, they are typically lists of keys on a specific resource similar to annotations. Some finalizers are added automatically by Kubernetes, but you can also add your own.” (kubernetes.io — Finalizers).
Custom finalizer names must be qualified with a DNS subdomain prefix:
metadata:
finalizers:
- example.com/cleanup-load-balancer # good — domain-prefixed
- my-operator.io/dump-state-to-s3 # good
- some-finalizer # rejected by API server
- mything # rejectedThe API server validates this on create / update and rejects unqualified custom finalizers. Built-in finalizers use the kubernetes.io/ or <kind>.kubernetes.io/ prefix or unprefixed names that were grandfathered in (e.g., foregroundDeletion).
The deletion state machine
The lifecycle of a finalized object under deletion (kubernetes.io — Finalizers):
“When you tell Kubernetes to delete an object that has finalizers specified for it, the Kubernetes API marks the object for deletion by populating
.metadata.deletionTimestamp, and returns a202status code (HTTP ‘Accepted’). The target object remains in a terminating state while the control plane, or other components, take the actions defined by the finalizers. After these actions are complete, the controller removes the relevant finalizers from the target object. When themetadata.finalizersfield is empty, Kubernetes considers the deletion complete and deletes the object.”
So the contract is:
| Step | API server | Object visible? |
|---|---|---|
| 1. User issues DELETE | sets deletionTimestamp to now | yes |
| 2. Finalizer list non-empty | refuses to remove from etcd | yes, in Terminating |
| 3. Controller-1 finishes its cleanup, removes its key | finalizers list shrinks | yes |
| 4. … all other controllers do the same | … | yes |
| 5. Last finalizer is removed | API server removes object from etcd | no — 404 thereafter |
Crucially, once deletionTimestamp is set, you cannot un-set it. The object is on a one-way trip to deletion; the only question is when (i.e., when the finalizer list empties). Adding a new finalizer to an already-deleting object is forbidden (the API server rejects the update); existing finalizers can only be removed, not added.
Built-in finalizers
Kubernetes ships several finalizers that are added automatically by the system. The most commonly encountered:
kubernetes.io/pv-protection— added by the PV protection controller toPersistentVolumeobjects whose status isBound. The docs give the canonical example: “A common example of a finalizer iskubernetes.io/pv-protection, which prevents accidental deletion ofPersistentVolumeobjects. When aPersistentVolumeobject is in use by a Pod, Kubernetes adds thepv-protectionfinalizer. If you try to delete thePersistentVolume, it enters a Terminating status, but the controller can’t delete it because the finalizer exists. When the Pod stops using thePersistentVolume, Kubernetes clears thepv-protectionfinalizer, and the controller deletes the volume.” (kubernetes.io — Finalizers).kubernetes.io/pvc-protection— same idea, onPersistentVolumeClaimobjects in use by a Pod.foregroundDeletion— added by the garbage collector to the owner during foreground cascade deletion. Removed by the GC when all blocking dependents are gone.orphan— used during orphan cascade to coordinate dependent cleanup.kubernetes— the Namespace finalizer, used to gate namespace deletion until every namespaced resource within is gone.
Operator-defined finalizers
Custom operators install their own finalizers. The canonical reconcile pattern, in pseudocode:
func (r *MyReconciler) Reconcile(ctx, req) (Result, error) {
obj := r.Get(req.NamespacedName)
myFinalizer := "myorg.io/cleanup"
if obj.DeletionTimestamp.IsZero() {
// Object is alive; ensure our finalizer is present.
if !contains(obj.Finalizers, myFinalizer) {
obj.Finalizers = append(obj.Finalizers, myFinalizer)
r.Update(obj)
return Result{}, nil // requeue
}
// ...normal reconcile (do the actual work)
return Result{}, nil
}
// DeletionTimestamp is set → we are being deleted.
if contains(obj.Finalizers, myFinalizer) {
if err := r.cleanupExternalResources(obj); err != nil {
// Cleanup failed — exponential backoff, try again.
return Result{}, err
}
// Cleanup succeeded — remove our finalizer.
obj.Finalizers = remove(obj.Finalizers, myFinalizer)
r.Update(obj)
}
return Result{}, nil // object will be reaped
// when last finalizer goes
}Three properties of this pattern are essential:
- Finalizer installation on first reconcile. The operator registers its finalizer the first time it sees the object. This ensures that every object the operator manages has the finalizer; objects created before the operator was installed get the finalizer on the next reconcile.
- Idempotent cleanup. The
cleanupExternalResourcesfunction must be idempotent — it will be called every reconcile until it succeeds. If it deletes a cloud LB that’s already gone, that’s fine; the call must be safe to repeat. - No “delete me” event. The operator never receives a deletion event — it sees
DeletionTimestamp != 0on the watched object. This is consistent with the level-triggered design of every K8s controller (see Kubernetes Control Loop Pattern).
Why namespace deletion uses this
Namespaces are deleted via finalizer. The namespace controller adds kubernetes to metadata.finalizers on every namespace; when a delete is requested, the controller transitions the namespace to status.phase: Terminating and proceeds to delete every namespaced resource within (iterating the discovery API). Only when all child resources are confirmed gone does it remove the kubernetes finalizer, allowing the namespace itself to be reaped. This is why deleting a namespace can take minutes (every resource type must be enumerated and deleted) and why a stuck dependent — usually a PVC or a CR with its own finalizer — keeps the namespace in Terminating forever.
Stuck-Finalizer Scenarios — The Most Common K8s Confusion
The single most-asked operations question about Kubernetes — “why won’t my namespace / PVC / CR delete?” — is almost always answered by: a finalizer is holding it. Diagnostic workflow:
# 1. Identify the stuck object.
kubectl get ns my-ns -o yaml | grep -A5 finalizers
kubectl get pvc -n my-ns my-pvc -o yaml | grep -A5 finalizers
# 2. Look for the controller that owns the finalizer (the prefix tells you).
# "kubernetes.io/pv-protection" → the in-tree PV controller is still
# holding the PV because a Pod is using it.
# "kubernetes" on a namespace → the namespace controller is still
# trying to delete contained resources.
# "myorg.io/something" → your own operator.
# 3. List remaining resources in the namespace (if it's a namespace).
kubectl api-resources --verbs=list --namespaced -o name \
| xargs -n1 kubectl get --show-kind --ignore-not-found -n my-ns
# 4. Find the resource that's actually blocking — it has its own finalizer
# that isn't clearing. Investigate that controller's logs.The “force delete” anti-pattern
The most common Stack Overflow answer is to patch the finalizers to null:
kubectl patch pvc my-pvc -n my-ns -p '{"metadata":{"finalizers":null}}' --type=merge
kubectl patch namespace my-ns -p '{"metadata":{"finalizers":null}}' --type=mergeThis works — the object is reaped — but it is almost always wrong, and the K8s documentation says so explicitly: “In cases where objects are stuck in a deleting state, avoid manually removing finalizers to allow deletion to continue. Finalizers are usually added to resources for a reason, so forcefully removing them can lead to issues in your cluster. This should only be done when the purpose of the finalizer is understood and is accomplished in another way.” (kubernetes.io — Finalizers).
What can go wrong:
- For a PVC: bypassing
pvc-protectiondeletes the PVC even though a running Pod is still attached, leaving the Pod’s mount in undefined state. - For an operator-managed CR: skipping the operator’s
cleanupfinalizer leaves orphan external resources (cloud LBs, IAM roles, database schemas) that nothing will ever clean up. These are invisible leaks — the K8s side is healthy, but cloud bills grow. - For a namespace: skipping the
kubernetesfinalizer can leave child resources orphaned in etcd, accessible only via direct API calls and invisible tokubectl.
The correct workflow is:
- Find the controller. The finalizer prefix names it.
- Fix it. Restart the controller, look at its logs, see why it isn’t clearing the finalizer.
- If the controller is gone forever (e.g., uninstalled operator, deprecated webhook), and you’ve manually verified the external state, then patch the finalizer. Treat it as a forensic operation, not a routine workflow.
Force-delete is a break-glass tool. Reaching for it routinely is the hallmark of an unmaintained cluster.
Configuration / API Surface
A minimal CRD with an operator-managed finalizer, line-by-line:
apiVersion: example.com/v1
kind: DatabaseBackup
metadata:
name: nightly-backup
finalizers: # set by the operator on
- example.com/dump-to-s3 # first reconcile; removed
# after S3 upload succeeds
spec:
source: postgres-primary
target: s3://my-bucket/backups/# Inspect the finalizers on a stuck object.
kubectl get databasebackup nightly-backup -o jsonpath='{.metadata.finalizers}'
# → ["example.com/dump-to-s3"]
# Check whether deletion has been requested.
kubectl get databasebackup nightly-backup -o jsonpath='{.metadata.deletionTimestamp}'
# → "2026-05-15T11:42:00Z"
# The operator's logs will reveal why the finalizer isn't being removed.
kubectl logs -n operators deployment/db-backup-operator | grep nightly-backupProgrammatically removing your own finalizer (Go, using controller-runtime):
import (
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)
// Add (idempotent — no-op if already present).
controllerutil.AddFinalizer(obj, "example.com/dump-to-s3")
// Remove (idempotent — no-op if already absent).
controllerutil.RemoveFinalizer(obj, "example.com/dump-to-s3")
// Persist the change.
if err := r.Update(ctx, obj); err != nil { ... }Failure Modes
-
Operator crash before finalizer removal. Cleanup completed, finalizer not yet removed, controller crashes. Symptom: object stuck in
Terminatingindefinitely. Mitigation: the operator must remove the finalizer atomically with (or after, but reliably retried) the cleanup. On restart, the operator’s reconciler sees the deletion timestamp again and re-runs the cleanup (which must be idempotent), then removes the finalizer. -
Cleanup never succeeds. External system permanently unreachable; operator retries forever. Symptom: object stuck. Mitigation: surface the failure via the object’s
status.conditions[], emit Events, and ideally implement a manual-override annotation that the operator respects (e.g.,example.com/force-cleanup-skip: true) — never via patching finalizers from the outside. -
Operator uninstalled while finalizer is present. No one is left to clear the finalizer. Symptom: every object the operator managed is stuck forever. Mitigation: operators should provide an uninstall procedure that removes finalizers from all managed objects before the operator itself is removed. Many operator authors miss this step.
-
Two operators with overlapping finalizers. Rare but possible if the same finalizer string is used by two different controllers. Mitigation: domain-prefix discipline (
myorg.io/...) and a one-finalizer-per-controller convention. -
Finalizer race with create. If a controller adds its finalizer in the reconcile loop, an object created and immediately deleted may never see the finalizer added. Mitigation: in a mutating admission webhook, add the finalizer at object creation (atomic with the create); or accept the small race window.
-
Foreground deletion blocked by stuck child finalizer. A Deployment in foreground cascade deletion will not be reaped because its ReplicaSet → Pod has a finalizer that doesn’t clear. The whole tree freezes. Diagnostic: descend the owner tree (
kubectl tree) and look at each level’s finalizers. -
Finalizers on cluster-scoped objects with cross-namespace cleanup needs. A finalizer on a cluster-scoped CR can drive cleanup of resources in many namespaces; if RBAC isn’t right, the operator can’t remove what it needs to, and gets stuck. Mitigation: give the operator a ClusterRole that covers all the namespaces it touches.
-
metadata.finalizersimmutable after deletionTimestamp set — except by patching null. The API server rejects adding a finalizer to an object whosedeletionTimestampis set; this is to prevent ever-extending deletion. Removal is allowed. Patching the whole array tonullbypasses both checks (the patch type matters — strategic merge with explicit null replaces the slice).
Alternatives and When to Choose Them
- Owner references (Owner References and Garbage Collection). Choose for in-cluster parent-child cleanup. Owner refs and finalizers compose: owner refs identify the dependents to delete; finalizers gate each one’s actual removal. Don’t try to express “delete this dependent when the parent is deleted” with a finalizer; use ownerReferences.
- Admission webhooks (validating). Choose for “do not allow this object to be deleted at all” — a validating webhook on DELETE can outright reject deletion. This is rarer; the finalizer’s “delay-then-allow” semantics fit more cleanup scenarios than “deny outright.”
- External state machines / Sagas. When the cleanup is a long-running, multi-step workflow with retries and compensations, a Saga orchestrator (Temporal, Cadence) called by the finalizer-removing controller is appropriate. The finalizer keeps the K8s object alive until the saga reports done.
- TTL controllers. For objects that should self-delete after some time (Jobs, Events), the TTL-After-Finished controller is the right mechanism — it issues a normal DELETE which then goes through the finalizer machinery. Finalizers are about gating; TTL is about scheduling.
- No finalizer. If cleanup is purely in-cluster and ownerReferences suffice, don’t add a finalizer. They are not free — they create stuck-object risk.
Production Notes
- The most-debugged K8s symptom. Every cluster operator eventually encounters “namespace stuck in Terminating.” The diagnostic playbook above is essentially canon. The Kyverno / Gatekeeper communities have policies that warn or block creation of objects with finalizers from unrecognized controllers — defense against orphaned-operator situations.
- PV / PVC protection finalizers are extremely common. Storage operators add them to gate volume detachment; running
kubectl delete pvcwhile the Pod is still using it does not race-delete the volume — the finalizer holds until the Pod is gone. - Cert-manager uses finalizers extensively. Deleting a
Certificateresource triggers cleanup of associated Secrets and ACME challenges via finalizer. - AWS Load Balancer Controller uses a finalizer on
Serviceobjects of typeLoadBalancerto ensure the cloud LB is deprovisioned before the Service is reaped. Skipping this finalizer can leak AWS LBs (billable). - Operator authors: include the uninstall finalizer dance. When your operator is uninstalled (via
helm uninstallor similar), the helm chart should run a hook that removes finalizers from all managed objects before the operator’s Deployment is deleted. Many operator authors miss this and leave users with stuck objects after uninstall. - k8s.af stories include outages where a “stuck Namespace” was force-deleted by patching finalizers, only for the underlying CRs (with their own external resources) to be orphaned; the cluster looked healthy and the cloud bill kept climbing for weeks.
- GitOps interactions. ArgoCD’s “prune” feature must respect finalizers — when ArgoCD is configured to delete an object that has a finalizer, the actual deletion may take time, and ArgoCD must keep showing the object as “deleting” until it’s truly gone. This is occasionally surprising to users expecting instant pruning.
See Also
- Owner References and Garbage Collection — the partner mechanism for in-cluster cascade
- Kubernetes Control Loop Pattern — finalizers are level-triggered, like every K8s control loop
- Operator Pattern — operators are the primary authors of custom finalizers
- Custom Resource Definition — finalizers on CRs are operator-managed
- Reconcile Function Patterns — the canonical reconcile shape that handles finalizer lifecycle
- Namespace — namespace deletion is finalizer-driven
- PersistentVolume —
pv-protectionfinalizer - PersistentVolumeClaim —
pvc-protectionfinalizer - Pod Lifecycle — Pods rarely have finalizers but deletion semantics share a model
- Admission Controllers — alternative gate on deletion (validating-admission DELETE)
- Velero — backup tool that must understand finalizers during restore
- Kubernetes MOC — umbrella index