Deletion in Kubernetes is instant — unless a finalizer says otherwise. Here’s how to run cleanup logic before a resource disappears.
Estimated Reading Time : 7m
The problem
When a user runs kubectl delete, Kubernetes removes the resource and garbage-collects its children. If your controller created external resources — a cloud database, a DNS record, an S3 bucket — those are orphaned. Kubernetes doesn’t know about them.
Finalizers give your controller a chance to clean up before the resource is actually deleted.
How finalizers work
A finalizer is a string in the resource’s .metadata.finalizers list. When Kubernetes sees a delete request for a resource with finalizers:
- It sets
.metadata.deletionTimestampon the resource instead of deleting it - The resource enters a “terminating” state — it still exists, but can’t be modified (except to remove finalizers)
- Your controller sees the
deletionTimestamp, runs cleanup logic, and removes its finalizer - When all finalizers are removed, Kubernetes deletes the resource
If your controller never removes the finalizer, the resource is stuck in terminating forever.
The pattern
const finalizerName = "backup.example.com/cleanup"
func (r *BackupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var backup v1alpha1.Backup
if err := r.Get(ctx, req.NamespacedName, &backup); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// Check if the resource is being deleted
if !backup.DeletionTimestamp.IsZero() {
// Run cleanup logic
if controllerutil.ContainsFinalizer(&backup, finalizerName) {
if err := r.cleanup(ctx, &backup); err != nil {
return ctrl.Result{}, err
}
// Remove the finalizer
controllerutil.RemoveFinalizer(&backup, finalizerName)
if err := r.Update(ctx, &backup); err != nil {
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
// Add finalizer if it doesn't exist
if !controllerutil.ContainsFinalizer(&backup, finalizerName) {
controllerutil.AddFinalizer(&backup, finalizerName)
if err := r.Update(ctx, &backup); err != nil {
return ctrl.Result{}, err
}
}
// Normal reconcile logic...
return ctrl.Result{}, nil
}
func (r *BackupReconciler) cleanup(ctx context.Context, backup *v1alpha1.Backup) error {
// Delete external resources: cloud storage, DNS records, etc.
log.FromContext(ctx).Info("Cleaning up external resources",
"backup", backup.Name,
)
return nil
}
The lifecycle
Create → Add finalizer → Normal reconciles → Delete requested →
deletionTimestamp set → Cleanup runs → Finalizer removed → Resource deleted
The finalizer must be added during a normal reconcile before the resource is deleted. If the resource is deleted before the finalizer is added, you miss your chance.
Finalizer naming
Use a DNS-style name scoped to your controller:
const finalizerName = "backup.example.com/cleanup"
This prevents collisions with other controllers that might manage the same resource.
Common mistakes
Cleanup logic that fails permanently. If your cleanup function always returns an error, the finalizer is never removed and the resource is stuck. Add a timeout or a way to skip cleanup for resources that can’t be cleaned up.
Not handling the “already deleted” case. Your cleanup logic should be idempotent. If the external resource was already deleted (manually or by a previous failed attempt), the cleanup should succeed silently.
Adding the finalizer too late. The finalizer must be on the resource before the delete request arrives. Add it at the beginning of your reconcile function, before any other logic.
Forgetting to remove the finalizer. If your cleanup succeeds but you don’t remove the finalizer, the resource is permanently stuck. Always remove the finalizer after successful cleanup.
When to use finalizers
External resources. If your controller provisions anything outside the cluster — cloud resources, external DNS, certificates — use a finalizer to clean them up.
Cross-namespace resources. Owner references don’t work across namespaces. If your controller creates resources in another namespace, use a finalizer to delete them.
Audit logging. If you need to record that a resource was deleted (e.g., for compliance), a finalizer gives you a hook to log before the resource is gone.
Don’t use finalizers for in-cluster cleanup. If all your child resources are in the same namespace and have owner references, Kubernetes garbage collection handles deletion automatically. Finalizers add complexity — only use them when garbage collection can’t do the job.