You run kubectl delete pod my-app-7d8f9c6b5-x2k4m. It hangs. Then kubectl get pods shows the pod in Terminating for the next 20 minutes. The API server accepted the delete, set a deletionTimestamp, and now it's waiting on something that will never happen.
What's actually happening here is a finalizer deadlock. A finalizer is a list entry in metadata.finalizers that tells the API server "don't actually remove this object until the controller responsible for this string cleans up." If that controller crashed, was uninstalled, or never existed (common with Helm charts that forget to ship the controller), the object sits in Terminating forever. The API server is doing exactly what it was told. The problem is nobody's answering the phone.
Here's the diagnostic that tells you which scenario you're in:
kubectl get pod my-app-7d8f9c6b5-x2k4m -o json | jq '.metadata.finalizers, .metadata.deletionTimestamp'
If finalizers is a non-empty array, you've got a deadlock. If it's empty and the pod is still stuck, the issue is on the kubelet side (node unreachable, CNI hung) — different article. Assuming you see a finalizer, here are the three causes in order of how often I run into them.
Cause 1: The finalizer's controller doesn't exist anymore
This is the big one. Roughly 7 out of 10 stuck-pod tickets I've seen trace back to this. Someone installed an operator, the operator registered a finalizer like example.com/cleanup, then the operator was uninstalled or its deployment scaled to zero. New pods stop getting the finalizer. Old pods that already have it become undeletable.
You'll see finalizers such as kubernetes.io/pvc-protection (that one's built-in and usually fine), but also third-party ones like batch.volcano.sh/job-controller, argoproj.io/rollout-cleanup, or cert-manager.io/issuer depending on what you deployed.
Confirm the controller is gone
# Grab the finalizer string
kubectl get pod my-app-7d8f9c6b5-x2k4m -o jsonpath='{.metadata.finalizers}'
# Then look for any workload that owns it
kubectl get deploy,sts,ds -A | grep -i
If nothing matches, the controller is gone. You have two choices: reinstall the controller so it can clean up, or strip the finalizer manually. Reinstalling is the correct fix when the controller has real cleanup logic — say, detaching an EBS volume or deleting an S3 bucket. Stripping is the pragmatic fix when it's just bookkeeping.
Strip the finalizer
kubectl patch pod my-app-7d8f9c6b5-x2k4m \
-p '{"metadata":{"finalizers":null}}' \
--type=merge
The reason this works: you're removing the last blocker in metadata.finalizers. Once the list is empty and deletionTimestamp is set, the API server finishes the delete on the next reconcile loop, usually within a second or two. You don't need --force. You don't need --grace-period=0. In fact, adding --force when you don't need it can leave orphaned resources on the node.
Don't reach for kubectl delete pod --force --grace-period=0 first. It bypasses the API server's normal path and can leave the kubelet holding a pod sandbox that never gets torn down. Strip the finalizer instead.
Cause 2: The controller is running but its webhook is unreachable
Second most common, and the sneakiest. The controller pod is Running and Ready. But when the API server calls the controller's mutating or validating webhook to approve the deletion, the webhook times out. The controller never gets the signal to remove its own finalizer.Typical trigger: your cluster's webhook service was set up with failurePolicy: Fail and the backing pods got evicted during a node drain. You'd see this show up as a webhook timeout in the API server logs.
Check webhook health
kubectl get mutatingwebhookconfigurations,validatingwebhookconfigurations
kubectl describe validatingwebhookconfiguration my-operator.example.com
kubectl get endpoints -n my-operator-system my-operator-webhook
If the endpoints list is empty, the service has no backing pods. The webhook is dead even though the Service object still exists.
Fix it the right way
Bring the webhook pods back. If they're gone for good, patch the webhook configuration to failurePolicy: Ignore so the API server stops blocking on it. Then delete the stuck pod normally.
kubectl patch validatingwebhookconfiguration my-operator.example.com \
--type=json \
-p '[{"op":"replace","path":"/webhooks/0/failurePolicy","value":"Ignore"}]'
Leave it at Ignore only temporarily. It defeats the point of the webhook. Once the operator's back, set it to Fail again.
Cause 3: The node hosting the pod is NotReady
The kubelet on the node is responsible for killing the container and reporting back. If the node is NotReady — network partition, kubelet crashed, disk pressure — the pod gets stuck at Terminating because the kubelet can't confirm the container is dead.
Spot it instantly:
kubectl get nodes
kubectl describe pod my-app-7d8f9c6b5-x2k4m | grep -A3 Events
If the pod's node shows NotReady, this is your cause. The fix depends on whether the node is coming back. If it's recoverable — kubelet restart, network blip — wait it out. The pod will delete once the kubelet reconnects.
If the node is dead for good, force-delete with a caveat: the kubelet-side resources (containers, volumes, CNI state) may not be cleaned up. That's fine if the node is going to the scrapyard. Don't do this if the node might come back.
kubectl delete pod my-app-7d8f9c6b5-x2k4m --grace-period=0 --force
Also check kubectl get pods -A --field-selector spec.nodeName= to see what else is stranded there. Killing one pod while leaving 40 others stuck is a bad day.
What about Namespaces stuck in Terminating?
Same disease, different symptom. When you delete a Namespace, the namespace controller waits for its finalizer kubernetes to be cleared, which requires all resources inside to be gone. If a single pod inside has a finalizer deadlock, the whole namespace sits in Terminating.
The fix is always to find the stuck resource first. Don't patch the namespace's own finalizers until you've done that — you'll end up with a namespace that's gone from the API but whose resources are still haunting the cluster.
kubectl api-resources --verbs=list --namespaced -o name | \
xargs -n1 kubectl get -n my-namespace --ignore-not-found -o name
That lists every namespaced resource still alive in there. Find the one with a finalizer, fix it, and the namespace unblocks itself.
Quick reference
| Symptom | Likely cause | Fix |
|---|---|---|
| Pod has finalizer, no matching controller workload | Controller uninstalled or scaled to zero | kubectl patch pod ... -p '{"metadata":{"finalizers":null}}' --type=merge |
| Controller running, webhook endpoints empty | Admission webhook unreachable, failurePolicy: Fail |
Restore webhook pods or patch failurePolicy to Ignore |
Node status NotReady |
Kubelet can't confirm container terminated | Wait for node, or --force --grace-period=0 if node is dead |
Namespace stuck Terminating |
Finalizer deadlock on a resource inside | Find and clear the inner resource's finalizer, then namespace resolves |
One last thing: audit your manifests. Finalizers are supposed to be set by controllers at runtime, not hardcoded in YAML. If you find metadata.finalizers in a Helm chart template, that's a bug — and it's the reason your next cluster upgrade is going to be painful.