Back to Blog

Kubernetes Finalizers, Owner References & Garbage Collection for the CKA Exam: Fixing Resources Stuck in Terminating

A hands-on CKA guide to how Kubernetes deletes things — deletionTimestamp, finalizers, owner references, and background, foreground and orphan cascading deletion — plus a troubleshooting playbook for PVCs, PVs, namespaces, custom resources and Pods stuck in Terminating, with the commands to diagnose the real cause before you ever touch a finalizer.

By Sailor Team , October 2, 2026

You run kubectl delete, the command hangs, and when you check from another terminal the object is still there with a status of Terminating. Ten minutes later it is still there. Almost every Kubernetes administrator meets this in their first year, and the usual internet advice — “just patch the finalizers to null” — fixes the symptom while skipping the question that matters: what was Kubernetes waiting for?

This guide answers that question properly. It covers the three mechanisms behind every deletion in Kubernetes — finalizers, owner references, and garbage collection — and then turns them into a troubleshooting playbook for the classic stuck objects: PersistentVolumeClaims, PersistentVolumes, namespaces, custom resources, and Pods.

A note on scope: the CKA curriculum does not list “finalizers” as a named bullet. The topic earns its place because it sits underneath two domains that are listed — Troubleshooting (30%), which asks you to troubleshoot clusters, nodes and cluster components, and Storage (10%), which asks you to manage persistent volumes and claims, where deletion protection is built on finalizers. Understanding the delete path makes a whole family of “why won’t this go away?” problems quick to diagnose, in the exam terminal and in production. For the broader diagnostic method, keep the CKA Troubleshooting Guide open alongside this article.

What Actually Happens When You Run kubectl delete

Deletion in Kubernetes is a request, not an instant removal. When the API server receives a DELETE for an object, one of two things happens:

  • No finalizers on the object — the object is removed from etcd (after any graceful period that applies, such as for Pods).
  • One or more finalizers on the object — the API server sets metadata.deletionTimestamp to the time of the request, returns 202 Accepted, and leaves the object in place. The object is now terminating. It stays visible in the API until every entry in metadata.finalizers has been removed, and then the API server deletes it automatically.

Two consequences follow, and both matter when you are troubleshooting:

  1. deletionTimestamp is a one-way door. Once it is set, you cannot cancel the deletion or “resurrect” the object. Your only options are to let the cleanup finish or to recreate the object afterwards.
  2. kubectl delete waits by default. The --wait flag defaults to true, so the command blocks until the object is really gone — which is why your terminal appears to hang. Pressing Ctrl+C stops the waiting, not the deletion. Use --wait=false if you want the prompt back immediately.

You can see both fields on any terminating object:

kubectl get pvc data-claim -o jsonpath='{.metadata.deletionTimestamp}{"\n"}{.metadata.finalizers}{"\n"}'

If deletionTimestamp has a value and finalizers is non-empty, you are looking at a finalizer wait. The rest of this guide is about working out who is supposed to remove that finalizer and why they have not.

Finalizers: Pre-Delete Hooks Written as Strings

A finalizer is just a string in metadata.finalizers. It carries no code. It is a message to a controller: “before this object disappears, you have cleanup to do.” The controller that owns the finalizer watches for objects with a deletionTimestamp, performs its cleanup, and then removes its own string from the list. When the list is empty, the object is deleted.

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

Finalizers you will meet on a real cluster

FinalizerFound onWhat it waits for
kubernetes.io/pvc-protectionPersistentVolumeClaimNo Pod is actively using the claim
kubernetes.io/pv-protectionPersistentVolumeThe volume is no longer bound to a claim
foregroundDeletionAny owner objectAll blocking dependents have been deleted first
orphanAny owner objectThe garbage collector has detached the dependents
kubernetes (in spec.finalizers)NamespaceEvery namespaced object inside has been deleted
service.kubernetes.io/load-balancer-cleanupService of type LoadBalancerThe cloud load balancer has been torn down
customresourcecleanup.apiextensions.k8s.ioCustomResourceDefinitionEvery custom resource of that type has been deleted
example.com/anythingCustom resourcesWhatever the operator that added it needs to clean up

Custom finalizer names must be qualified with a domain prefix, in the example.com/name form — that prefix is usually your best clue about which controller or operator is responsible.

The key insight for troubleshooting: a finalizer that never clears means its controller is not doing its job — because the condition it waits for is still true (a Pod still mounts the PVC), because the controller is not running (an operator was uninstalled), or because the controller cannot reach something it needs (a cloud API, an aggregated API server).

Owner References: How Kubernetes Knows What Belongs to What

Finalizers decide when an object may disappear. Owner references decide what else should disappear with it. Every dependent object records its owner in metadata.ownerReferences:

kubectl get replicaset -l app=web -o yaml | grep -A 8 ownerReferences
ownerReferences:
  - apiVersion: apps/v1
    kind: Deployment
    name: web
    uid: 4f6c1c3e-6f0a-4a5e-9c0e-2f1d3b9a7e11
    controller: true
    blockOwnerDeletion: true

The fields are worth knowing individually:

  • apiVersion, kind, name, uid identify the owner. The uid matters: if you delete a Deployment and create a new one with the same name, it has a different UID and is not the owner of the old ReplicaSets.
  • controller: true marks the managing controller. An object can have several owners but only one managing controller.
  • blockOwnerDeletion: true means this dependent must be gone before the owner can be removed in a foreground deletion. Kubernetes sets it automatically when a built-in controller creates the reference.

Controllers set owner references for you. A Deployment owns its ReplicaSets; a ReplicaSet owns its Pods; a Job owns its Pods; a CronJob owns its Jobs; a StatefulSet and a DaemonSet own their Pods. That chain is what makes kubectl delete deployment web remove the Pods too, even though you never named them.

A fast way to walk the chain for any Pod:

kubectl get pod web-7d4b9c6f5-x2k8p \
  -o jsonpath='{.metadata.ownerReferences[0].kind}/{.metadata.ownerReferences[0].name}{"\n"}'
# ReplicaSet/web-7d4b9c6f5

If you want more practice shaping output like this, the kubectl output formatting guide covers JSONPath and custom columns in depth.

The namespace rule

Owner references cannot cross namespaces. A namespaced dependent may be owned by an object in the same namespace or by a cluster-scoped object; a cluster-scoped dependent may only be owned by a cluster-scoped object. If the garbage collector finds an invalid reference, it emits a warning event with the reason OwnerRefInvalidNamespace, which you can search for directly:

kubectl get events -A --field-selector=reason=OwnerRefInvalidNamespace

Cascading Deletion: Background, Foreground and Orphan

When you delete an owner, the garbage collector — a controller inside kube-controller-manager — decides what happens to its dependents. There are three policies, selected with --cascade on kubectl delete or propagationPolicy in the API.

Policykubectl flagWhat happensOwner visible during cleanup?
Background (default)--cascade=backgroundOwner is deleted immediately; the garbage collector removes dependents afterwardsNo
Foreground--cascade=foregroundOwner gets deletionTimestamp and the foregroundDeletion finalizer; dependents with blockOwnerDeletion: true are deleted first; then the ownerYes, as terminating
Orphan--cascade=orphanOwner gets the orphan finalizer; the garbage collector strips the owner references from dependents; owner is deleted; dependents keep runningBriefly

Background: the default

kubectl create deployment web --image=nginx:1.27 --replicas=3
kubectl delete deployment web

The Deployment vanishes at once. For a moment you can still see the ReplicaSet and Pods; the garbage collector notices their owner is gone and deletes them in turn.

Foreground: dependents first

kubectl delete deployment web --cascade=foreground --wait=false
kubectl get deployment web -o jsonpath='{.metadata.finalizers}{"\n"}'
# ["foregroundDeletion"]

The Deployment remains readable until its ReplicaSets and Pods are gone. Use foreground when something downstream must not observe “owner gone, children still running” — for example a script that recreates the workload and would otherwise race against the old Pods.

Orphan: delete the controller, keep the Pods

kubectl delete deployment web --cascade=orphan
kubectl get rs,pods -l app=web

The ReplicaSet and its Pods keep running, now without a Deployment above them. This is the standard technique for changing a field the API treats as immutable without downtime: orphan the controller, then recreate it with the same selector, and the new controller adopts the existing objects. The CKA StatefulSets guide points to this move for changing the otherwise immutable volumeClaimTemplates.

The same three policies are available through the raw API, which is useful to recognise even if you rarely type it:

kubectl proxy --port=8080 &
curl -X DELETE localhost:8080/apis/apps/v1/namespaces/default/deployments/web \
  -H "Content-Type: application/json" \
  -d '{"kind":"DeleteOptions","apiVersion":"v1","propagationPolicy":"Foreground"}'

Troubleshooting Playbook: Objects Stuck in Terminating

The method is the same for every resource type:

  1. Confirm it is a finalizer wait — deletionTimestamp set, finalizers non-empty.
  2. Read the finalizer name and identify its controller.
  3. Find out why the controller’s condition is not met.
  4. Fix that. Only if the controller is permanently gone do you remove the finalizer by hand.

A PVC stuck in Terminating

kubectl get pvc
# NAME         STATUS        VOLUME    CAPACITY   ACCESS MODES   STORAGECLASS   AGE
# data-claim   Terminating   pv-data   5Gi        RWO            standard       3d

The finalizer is kubernetes.io/pvc-protection — storage object in use protection. It exists so that deleting a claim cannot pull storage out from under a running Pod. The claim is removed only when no Pod is actively using it.

kubectl describe pvc data-claim | grep -E "Status|Finalizers|Used By"
# Status:        Terminating (lasts 4m12s)
# Finalizers:    [kubernetes.io/pvc-protection]
# Used By:       db-0

Used By names the culprit. Delete or reschedule that Pod — or scale down the controller that keeps recreating it — and the claim disappears on its own:

kubectl scale statefulset db --replicas=0
kubectl get pvc data-claim
# Error from server (NotFound): persistentvolumeclaims "data-claim" not found

No finalizer patching required. This is the intended behaviour, not a fault.

A PV stuck in Terminating

Same idea one level down. kubernetes.io/pv-protection holds a PersistentVolume while it is still bound to a claim:

kubectl get pv pv-data -o jsonpath='{.status.phase}{"  "}{.spec.claimRef.namespace}/{.spec.claimRef.name}{"\n"}'
# Bound  default/data-claim

Delete the claim first (and whatever Pod is holding the claim), and the volume follows. What happens to the underlying storage afterwards depends on the reclaim policy; the CKA storage guide covers Retain versus Delete in detail.

A namespace stuck in Terminating

This is the most searched-for variant, and the one where reaching for the force option does the most damage. A namespace carries the kubernetes finalizer in spec.finalizers. The namespace controller deletes every namespaced object inside and only then removes that finalizer. So a stuck namespace means something inside could not be deleted, or could not even be listed.

Kubernetes tells you which, in the namespace’s status conditions:

kubectl get namespace team-a -o jsonpath='{range .status.conditions[*]}{.type}{"\t"}{.status}{"\t"}{.message}{"\n"}{end}'
ConditionMeaning when TrueWhere to look next
NamespaceDeletionDiscoveryFailureThe controller could not discover all API groupskubectl get apiservices — an aggregated API is unavailable
NamespaceDeletionGroupVersionParsingFailureAn API group/version could not be parsedThe API service or CRD named in the message
NamespaceDeletionContentFailureDeleting some content returned errorsThe message names the resource; check its controller or webhook
NamespaceContentRemainingObjects still exist in the namespaceThe message lists resource types and counts
NamespaceFinalizersRemainingRemaining objects are held by their own finalizersThe message lists the finalizer names and counts

Cause 1: an unavailable aggregated API. If an APIService — metrics-server is the usual suspect — is failing, the namespace controller cannot enumerate everything it must delete, so it refuses to finish.

kubectl get apiservices | grep -v True
# v1beta1.metrics.k8s.io   kube-system/metrics-server   False (MissingEndpoints)   41d

Fix the backing Deployment, or, if the component was removed and left its registration behind, delete the stale APIService. The namespace then completes within moments. The monitoring and metrics-server guide covers installing and verifying metrics-server itself.

Cause 2: objects inside are held by their own finalizers. List everything that is still there. kubectl get all is not enough — it skips most resource types, including custom resources:

kubectl api-resources --verbs=list --namespaced -o name \
  | xargs -n 1 kubectl get --show-kind --ignore-not-found -n team-a

Whatever that prints is what the namespace is waiting on. Treat each leftover as its own stuck object and apply the playbook to it.

Custom resources stuck after an operator was removed

A frequent pattern: someone uninstalls an operator, then tries to delete its custom resources or the namespace they live in. The custom resources carry the operator’s finalizer, and the only process that knew how to remove it no longer exists.

kubectl get databases.example.com -n team-a orders-db \
  -o jsonpath='{.metadata.finalizers}{"\n"}'
# ["example.com/database-cleanup"]

The correct order is to delete the custom resources while the operator is still running, then remove the operator, then the CRDs. If it is already too late, you have two options: reinstall the operator briefly so it can finish its cleanup, or — accepting that any external resources it managed are now your responsibility — remove the finalizer manually (next section). The same logic applies to a CRD that will not delete: its customresourcecleanup.apiextensions.k8s.io finalizer waits for every instance of the type to be gone. For how CRDs and operators fit together, see the CRDs and operators guide.

A Pod stuck in Terminating

Pods are different, and it is worth not confusing them with the cases above. A terminating Pod is usually not waiting on a finalizer. It is waiting for the kubelet on its node to confirm the containers have stopped. Check in this order:

kubectl get pod web-0 -o wide          # which node?
kubectl get node worker-2              # is that node Ready?
kubectl get pod web-0 -o jsonpath='{.metadata.finalizers}{"  "}{.spec.terminationGracePeriodSeconds}{"\n"}'
  • Node NotReady or kubelet down — the API server cannot get confirmation. Fix the node (systemctl status kubelet, journalctl -u kubelet) and the Pod clears. See the node maintenance guide for the surrounding workflow.
  • Long grace period or a slow preStop hook — the Pod is shutting down as designed; it will be killed when the grace period ends. The Pod lifecycle and graceful shutdown guide walks through that sequence.
  • A finalizer really is present — rare for Pods, but some controllers add one. Apply the playbook.

kubectl delete pod web-0 --grace-period=0 --force removes the Pod object from the API immediately without waiting for the kubelet. That is acceptable for a stateless Pod on a node you know is dead. For a StatefulSet Pod it is risky: the old container may still be running on an unreachable node while a replacement with the same identity starts elsewhere, and two writers on one volume is how data gets corrupted.

Removing a Finalizer by Hand — the Last Resort

The Kubernetes documentation is direct about this: finalizers are usually added for a reason, and forcefully removing them can lead to issues in your cluster. Removing one does not perform the cleanup; it tells Kubernetes to stop waiting for cleanup that will now never happen. Depending on the finalizer, that can leave behind a cloud load balancer you are still paying for, a disk nobody tracks, or namespaced objects orphaned in etcd.

Do it only when you have established that the responsible controller is gone for good and you have handled, or accepted, what it would have cleaned up.

# Remove all finalizers from one object
kubectl patch databases.example.com orders-db -n team-a \
  --type=merge -p '{"metadata":{"finalizers":null}}'

To remove a single finalizer and leave the others, edit the object (kubectl edit) and delete only that line.

Namespaces are a special case because their finalizer lives in spec.finalizers and can only be changed through the finalize subresource:

kubectl get namespace team-a -o json \
  | jq '.spec.finalizers = []' \
  | kubectl replace --raw "/api/v1/namespaces/team-a/finalize" -f -

Be clear about what this does: the namespace object disappears, but anything that was still inside it is not deleted — it is simply no longer reachable through a namespace. If the root cause was an unavailable API service, fix that instead and let the controller finish the job correctly.

The Rest of Garbage Collection

Owner-based cascading deletion is the part of garbage collection you interact with most, but the term covers other automatic cleanup that shows up in cluster administration:

  • Finished Jobs. A Job with spec.ttlSecondsAfterFinished is deleted, along with its Pods, that many seconds after it completes. Without it, finished Jobs stay until you or their owning CronJob’s history limits remove them. See the Jobs and CronJobs guide.
  • Terminated Pods. The Pod garbage collector in kube-controller-manager removes terminated Pods once their number exceeds --terminated-pod-gc-threshold (12500 by default).
  • Unused images. The kubelet checks image disk usage every five minutes. When usage passes imageGCHighThresholdPercent (default 85) it deletes unused images, least recently used first, until usage falls to imageGCLowThresholdPercent (default 80). Both are fields in the kubelet configuration file.
  • Unused containers. The kubelet removes dead containers every minute.
  • Released volumes. A dynamically provisioned PersistentVolume with reclaim policy Delete is removed, with its backing storage, when its claim is deleted.

The kubelet thresholds are a useful thing to recognise when a node reports disk pressure: image garbage collection is the kubelet’s first line of defence before it starts evicting Pods. Avoid running external cleanup tools against the container runtime on a node — the kubelet expects to manage those images and containers itself.

Quick Reference: Symptom to Cause to Fix

SymptomMost likely causeFirst commandFix
kubectl delete hangsWaiting on a finalizer (--wait=true)kubectl get <obj> -o jsonpath='{.metadata.finalizers}'Identify the finalizer’s controller
PVC TerminatingA Pod still uses the claimkubectl describe pvc <name> (see Used By)Delete or scale down the Pod
PV TerminatingStill bound to a PVCkubectl get pv <name> -o yaml (see claimRef)Delete the PVC first
Namespace TerminatingUnavailable APIService, or leftover objects with finalizerskubectl get ns <name> -o yaml (see status.conditions)Repair or remove the APIService; clear leftovers
Custom resource TerminatingIts operator is no longer runningkubectl get <cr> -o yaml (see finalizers)Restore the operator, or remove the finalizer knowingly
Pod TerminatingNode NotReady, or grace period still runningkubectl get pod -o wide, kubectl get nodeFix the kubelet; force delete only if the node is dead
Pods remain after deleting their controller--cascade=orphan was usedkubectl get pod <name> -o yaml (no ownerReferences)Delete them directly, or recreate the controller to adopt them
Deployment visible but terminatingForeground deletion in progresskubectl get deploy <name> -o jsonpath='{.metadata.finalizers}'Wait; check why dependents are slow

A 15-Minute Practice Lab

Reading about deletion is not the same as watching it. On any practice cluster:

# 1. Trigger PVC protection
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: lab-claim
spec:
  accessModes: ["ReadWriteOnce"]
  resources:
    requests:
      storage: 100Mi
---
apiVersion: v1
kind: Pod
metadata:
  name: lab-pod
spec:
  containers:
    - name: app
      image: busybox:1.36
      command: ["sleep", "3600"]
      volumeMounts:
        - name: data
          mountPath: /data
  volumes:
    - name: data
      persistentVolumeClaim:
        claimName: lab-claim
EOF

kubectl delete pvc lab-claim --wait=false
kubectl get pvc lab-claim                 # Terminating
kubectl describe pvc lab-claim | grep -E "Finalizers|Used By"
kubectl delete pod lab-pod                # the PVC now disappears

# 2. Compare the three cascade policies
kubectl create deployment gc-demo --image=nginx:1.27 --replicas=2
kubectl delete deployment gc-demo --cascade=orphan
kubectl get rs,pods -l app=gc-demo        # still running, no Deployment
kubectl create deployment gc-demo --image=nginx:1.27 --replicas=2
kubectl get rs -l app=gc-demo -o jsonpath='{.items[*].metadata.ownerReferences[*].name}{"\n"}'
kubectl delete deployment gc-demo --cascade=foreground

Repeat it until you can predict each result before you press Enter. If you do not have a cluster handy, How to Practice CKA for Free lists the options.

Common Mistakes

  • Patching finalizers before reading them. The finalizer name tells you what is wrong. Removing it discards the diagnosis along with the cleanup.
  • Force-finalizing a namespace when an APIService is down. The real fault remains, and the next namespace you delete will hang the same way.
  • Using kubectl get all to prove a namespace is empty. It omits custom resources, Secrets, ConfigMaps, and more. Enumerate with kubectl api-resources.
  • Uninstalling an operator before deleting its custom resources. That creates the stuck objects in the first place.
  • Force-deleting StatefulSet Pods on unreachable nodes. You can end up with two Pods of the same identity writing to the same data.
  • Expecting Ctrl+C to cancel a deletion. It only stops kubectl from waiting; deletionTimestamp is already set.
  • Forgetting that orphaned Pods no longer have a controller. Nothing will restart or scale them until something adopts them.

Frequently Asked Questions

Are finalizers explicitly on the CKA curriculum?

No, there is no bullet that names them. They are part of the knowledge behind listed objectives — troubleshooting clusters and cluster components, and managing persistent volumes and claims, where in-use protection is implemented with finalizers. Treat this as supporting knowledge that makes troubleshooting tasks faster, not as a topic to memorise in isolation.

Why is my namespace stuck in Terminating?

Because the namespace controller could not delete, or could not list, everything inside it. Read status.conditions on the namespace: NamespaceDeletionDiscoveryFailure points to an unavailable aggregated API (check kubectl get apiservices), while NamespaceContentRemaining and NamespaceFinalizersRemaining tell you which objects are left and which finalizers are holding them.

Is it safe to remove a finalizer with kubectl patch?

Only when the controller that owns the finalizer is permanently gone and you have dealt with the cleanup it would have done. For built-in protections such as kubernetes.io/pvc-protection, the right fix is to remove the condition — stop the Pod using the claim — not the finalizer.

What is the difference between foreground and background cascading deletion?

In background deletion, the default, the owner is removed immediately and dependents are cleaned up afterwards. In foreground deletion the owner stays visible with the foregroundDeletion finalizer until its blocking dependents are deleted, and is removed last.

How do I delete a Deployment but keep its Pods running?

Use kubectl delete deployment <name> --cascade=orphan. The garbage collector removes the owner references from the dependents instead of deleting them. A new controller with a matching selector can adopt them later.

Why does a deleted PVC still show up as Terminating?

The kubernetes.io/pvc-protection finalizer keeps the claim until no Pod is using it. Run kubectl describe pvc <name> and look at Used By; once that Pod is gone the claim is removed automatically.

Can I undo a delete once the object is Terminating?

No. Once deletionTimestamp is set the deletion cannot be reversed. If you still need the object, save its manifest with kubectl get -o yaml, let the deletion complete, and recreate it.

Does kubectl delete —force remove finalizers?

No. --grace-period=0 --force skips the graceful termination wait for Pods; it does not clear metadata.finalizers. An object held by a finalizer stays terminating until the finalizer is removed.

Conclusion & Next Steps

Every “stuck in Terminating” problem reduces to the same three questions: is a finalizer present, which controller owns it, and why has that controller not finished? Finalizers hold an object until cleanup is done; owner references tell the garbage collector what to clean up with it; and the cascade policy — background, foreground or orphan — decides the order. Read the finalizer name and the status conditions first, fix the cause, and keep manual finalizer removal for the cases where the controller is truly gone.

Working through these scenarios against a live cluster under a clock is the quickest way to make the diagnosis automatic. Sailor.sh’s Certified Kubernetes Administrator (CKA) Mock Exam Bundle gives you a browser-based terminal on a real cluster with exam-style troubleshooting and storage tasks, so you can practise reading cluster state and fixing it within time limits.

To keep building out the surrounding skills, continue with the CKA Troubleshooting Guide for the full diagnostic method, the CKA storage guide for volumes, claims and reclaim policies, the CKA 30-day study plan to schedule your preparation, and the CKA Exam Guide 2026 for the blueprint and logistics.

Limited Time Offer: Get 80% off all Mock Exam Bundles | Sale ends in 7 days. Start learning today.

Claim Now