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.deletionTimestampto the time of the request, returns202 Accepted, and leaves the object in place. The object is now terminating. It stays visible in the API until every entry inmetadata.finalizershas been removed, and then the API server deletes it automatically.
Two consequences follow, and both matter when you are troubleshooting:
deletionTimestampis 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.kubectl deletewaits by default. The--waitflag defaults totrue, 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=falseif 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
| Finalizer | Found on | What it waits for |
|---|---|---|
kubernetes.io/pvc-protection | PersistentVolumeClaim | No Pod is actively using the claim |
kubernetes.io/pv-protection | PersistentVolume | The volume is no longer bound to a claim |
foregroundDeletion | Any owner object | All blocking dependents have been deleted first |
orphan | Any owner object | The garbage collector has detached the dependents |
kubernetes (in spec.finalizers) | Namespace | Every namespaced object inside has been deleted |
service.kubernetes.io/load-balancer-cleanup | Service of type LoadBalancer | The cloud load balancer has been torn down |
customresourcecleanup.apiextensions.k8s.io | CustomResourceDefinition | Every custom resource of that type has been deleted |
example.com/anything | Custom resources | Whatever 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,uididentify the owner. Theuidmatters: 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: truemarks the managing controller. An object can have several owners but only one managing controller.blockOwnerDeletion: truemeans 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.
| Policy | kubectl flag | What happens | Owner visible during cleanup? |
|---|---|---|---|
| Background (default) | --cascade=background | Owner is deleted immediately; the garbage collector removes dependents afterwards | No |
| Foreground | --cascade=foreground | Owner gets deletionTimestamp and the foregroundDeletion finalizer; dependents with blockOwnerDeletion: true are deleted first; then the owner | Yes, as terminating |
| Orphan | --cascade=orphan | Owner gets the orphan finalizer; the garbage collector strips the owner references from dependents; owner is deleted; dependents keep running | Briefly |
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:
- Confirm it is a finalizer wait —
deletionTimestampset,finalizersnon-empty. - Read the finalizer name and identify its controller.
- Find out why the controller’s condition is not met.
- 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}'
| Condition | Meaning when True | Where to look next |
|---|---|---|
NamespaceDeletionDiscoveryFailure | The controller could not discover all API groups | kubectl get apiservices — an aggregated API is unavailable |
NamespaceDeletionGroupVersionParsingFailure | An API group/version could not be parsed | The API service or CRD named in the message |
NamespaceDeletionContentFailure | Deleting some content returned errors | The message names the resource; check its controller or webhook |
NamespaceContentRemaining | Objects still exist in the namespace | The message lists resource types and counts |
NamespaceFinalizersRemaining | Remaining objects are held by their own finalizers | The 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
NotReadyor 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
preStophook — 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.ttlSecondsAfterFinishedis 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-managerremoves 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 toimageGCLowThresholdPercent(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
Deleteis 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
| Symptom | Most likely cause | First command | Fix |
|---|---|---|---|
kubectl delete hangs | Waiting on a finalizer (--wait=true) | kubectl get <obj> -o jsonpath='{.metadata.finalizers}' | Identify the finalizer’s controller |
PVC Terminating | A Pod still uses the claim | kubectl describe pvc <name> (see Used By) | Delete or scale down the Pod |
PV Terminating | Still bound to a PVC | kubectl get pv <name> -o yaml (see claimRef) | Delete the PVC first |
Namespace Terminating | Unavailable APIService, or leftover objects with finalizers | kubectl get ns <name> -o yaml (see status.conditions) | Repair or remove the APIService; clear leftovers |
Custom resource Terminating | Its operator is no longer running | kubectl get <cr> -o yaml (see finalizers) | Restore the operator, or remove the finalizer knowingly |
Pod Terminating | Node NotReady, or grace period still running | kubectl get pod -o wide, kubectl get node | Fix the kubelet; force delete only if the node is dead |
| Pods remain after deleting their controller | --cascade=orphan was used | kubectl get pod <name> -o yaml (no ownerReferences) | Delete them directly, or recreate the controller to adopt them |
| Deployment visible but terminating | Foreground deletion in progress | kubectl 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 allto prove a namespace is empty. It omits custom resources, Secrets, ConfigMaps, and more. Enumerate withkubectl 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
kubectlfrom waiting;deletionTimestampis 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.