Skip to main content
πŸš€ Claude Code Bootcamp β€” May 30 5 hours from prompting to production. Build 10 real-world projects with AI-assisted development. Register Now
Kubernetes finalizers explained stuck resources
Platform Engineering

Kubernetes Finalizers Explained: Why Resources Get Stuck Deleting

Understand how Kubernetes finalizers work, why resources get stuck in Terminating state, and how to safely remove finalizers to unblock deletions in production clusters.

LB
Luca Berton
Β· 2 min read

What Are Finalizers?

Finalizers are keys in a resource’s metadata.finalizers array that tell Kubernetes: β€œDo not delete this object until I have cleaned up.” They act as pre-delete hooks that guarantee external dependencies are resolved before garbage collection.

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: my-data
  finalizers:
    - kubernetes.io/pvc-protection

When you run kubectl delete, Kubernetes sets metadata.deletionTimestamp but does NOT remove the object. The controller responsible for the finalizer must:

  1. Perform its cleanup logic
  2. Remove its finalizer from the array
  3. Only then does Kubernetes actually delete the object

Common Finalizers You Will Encounter

FinalizerPurposeController
kubernetes.io/pvc-protectionPrevents PVC deletion while mountedPVC Protection Controller
kubernetes.io/pv-protectionPrevents PV deletion while boundPV Protection Controller
foregroundDeletionWaits for dependents to delete firstGarbage Collector
orphanLeaves dependents runningGarbage Collector
external-dnsRemoves DNS records before ingress deletionExternalDNS
helm.sh/hook-delete-policyHelm release cleanupHelm/Tiller
Custom finalizersOperator-specific cleanupCustom operators

Why Resources Get Stuck in Terminating

A resource stays in Terminating state when:

  1. Controller is down β€” the controller that should process the finalizer is not running
  2. Controller is broken β€” the cleanup logic is failing (e.g., cannot reach external API)
  3. Namespace deletion β€” namespace is terminating but contains resources with unprocessable finalizers
  4. Orphaned finalizer β€” a CRD was deleted before its controller could clean up

Check a stuck resource:

kubectl get pod stuck-pod -o jsonpath='{.metadata.finalizers}'
# ["my-operator.io/cleanup"]

kubectl get pod stuck-pod -o jsonpath='{.metadata.deletionTimestamp}'
# 2026-06-01T10:30:00Z

How to Unstick Resources Safely

Step 1: Identify What Is Stuck

# Find all resources stuck in Terminating
kubectl get all --all-namespaces | grep Terminating

# For namespaces specifically
kubectl get namespaces | grep Terminating

# Check finalizers on a specific resource
kubectl get namespace stuck-ns -o json | jq '.spec.finalizers'
kubectl get namespace stuck-ns -o json | jq '.metadata.finalizers'

Step 2: Understand Why

Before removing finalizers, understand what cleanup they represent:

# Check events for the resource
kubectl describe namespace stuck-ns

# Check if the responsible controller is running
kubectl get pods -n kube-system | grep controller

# Check operator logs
kubectl logs -n operator-system deploy/my-operator

Step 3: Remove Finalizers (When Safe)

For regular resources:

kubectl patch pod stuck-pod -p '{"metadata":{"finalizers":null}}' --type=merge

For namespaces (requires API call):

# Export namespace spec
kubectl get namespace stuck-ns -o json > ns.json

# Remove finalizers from spec
jq '.spec.finalizers = []' ns.json > ns-clean.json

# Replace via API
kubectl replace --raw "/api/v1/namespaces/stuck-ns/finalize" -f ns-clean.json

For CRDs:

kubectl patch crd my-crd.example.com \
  -p '{"metadata":{"finalizers":[]}}' --type=merge

Step 4: Bulk Cleanup

When multiple resources are stuck (common during CRD/operator uninstall):

# Remove finalizers from all resources of a type in a namespace
kubectl get configmaps -n stuck-ns -o name | while read cm; do
  kubectl patch "$cm" -n stuck-ns \
    -p '{"metadata":{"finalizers":null}}' --type=merge
done

# Nuclear option: all resources in a namespace
for resource in $(kubectl api-resources --namespaced -o name); do
  kubectl get "$resource" -n stuck-ns -o name 2>/dev/null | while read obj; do
    kubectl patch "$obj" -n stuck-ns \
      -p '{"metadata":{"finalizers":null}}' --type=merge 2>/dev/null
  done
done

Writing Your Own Finalizer

If you are building an operator, here is the pattern:

const myFinalizer = "mycompany.io/cleanup"

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

    // Resource is being deleted
    if !obj.DeletionTimestamp.IsZero() {
        if containsString(obj.Finalizers, myFinalizer) {
            // Perform cleanup
            if err := r.cleanupExternalResources(obj); err != nil {
                return ctrl.Result{}, err
            }
            // Remove finalizer
            obj.Finalizers = removeString(obj.Finalizers, myFinalizer)
            if err := r.Update(ctx, obj); err != nil {
                return ctrl.Result{}, err
            }
        }
        return ctrl.Result{}, nil
    }

    // Add finalizer if not present
    if !containsString(obj.Finalizers, myFinalizer) {
        obj.Finalizers = append(obj.Finalizers, myFinalizer)
        if err := r.Update(ctx, obj); err != nil {
            return ctrl.Result{}, err
        }
    }

    // Normal reconciliation logic
    return ctrl.Result{}, nil
}

Prevention: Best Practices

  1. Always test operator uninstall β€” simulate removing your operator and verify cleanup completes
  2. Add timeouts to cleanup logic β€” do not let finalizers hang forever
  3. Log finalizer actions β€” make it obvious what cleanup is happening
  4. Document your finalizers β€” other team members need to know what they do
  5. Use owner references β€” prefer garbage collection over finalizers when possible
# Owner references auto-delete dependents (no finalizer needed)
apiVersion: v1
kind: ConfigMap
metadata:
  name: my-config
  ownerReferences:
    - apiVersion: apps/v1
      kind: Deployment
      name: my-app
      uid: d9607e19-f88f-11e6-a518-42010a800195

Debugging Checklist

# 1. What finalizers are present?
kubectl get <resource> <name> -o jsonpath='{.metadata.finalizers}'

# 2. When was deletion requested?
kubectl get <resource> <name> -o jsonpath='{.metadata.deletionTimestamp}'

# 3. Is the controller running?
kubectl get pods -A | grep -i <operator-name>

# 4. What do the controller logs say?
kubectl logs -n <operator-ns> deploy/<operator> --tail=50

# 5. Are there events?
kubectl describe <resource> <name> | tail -20

Deepen Your Kubernetes Skills

If you found this article useful, check out my books for hands-on Kubernetes mastery:

Both books follow the same practical, example-driven approach you see in my articles.

Free 30-min AI & Cloud consultation

Book Now