Writing a Reconciler: ctrl.Request & Reconcile Loop
The reconciler is the heart of an operator: a function Kubernetes calls whenever a watched object needs attention.
Search across all documentation pages
The reconciler is the heart of an operator: a function Kubernetes calls whenever a watched object needs attention.
ctrl.Request tells you which object to fetch; your code drives the cluster toward the spec and returns when to run again.
Reconcile(ctx, req) must be idempotent and safe to run many times for the same object.
Fetch the latest CR with r.Get, compare spec to child resources, create or patch gaps, then update status.
Return ctrl.Result{} on success, ctrl.Result{RequeueAfter: duration} to wait, or an error to trigger exponential backoff.
Treat NotFound as success when the object was deleted.
Quick-reference recipe card - copy-paste ready.
func (r *GuestbookReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var gb webappv1.Guestbook
if err := r.Get(ctx, req.NamespacedName, &gb); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if err := r.ensureFrontend(ctx, &gb); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}When to reach for this:
package controller
import (
"context"
"time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
webappv1 "example.com/guestbook-operator/api/v1"
)
const guestbookFinalizer = "webapp.example.com/finalizer"
func (r *GuestbookReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
var gb webappv1.Guestbook
if err := r.Get(ctx, req.NamespacedName, &gb); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if gb.DeletionTimestamp.IsZero() {
if !controllerutil.ContainsFinalizer(&gb, guestbookFinalizer) {
controllerutil.AddFinalizer(&gb, guestbookFinalizer)
if err := r.Update(ctx, &gb); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{Requeue: true}, nil
}
} else {
if err := r.teardown(ctx, &gb); err != nil {
return ctrl.Result{}, err
}
controllerutil.RemoveFinalizer(&gb, guestbookFinalizer)
if err := r.Update(ctx, &gb); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
dep := &appsv1.Deployment{}
depName := types.NamespacedName{Name: gb.Name + "-frontend", Namespace: gb.Namespace}
err := r.Get(ctx, depName, dep)
if apierrors.IsNotFound(err) {
dep = r.desiredDeployment(&gb)
if err := controllerutil.SetControllerReference(&gb, dep, r.Scheme); err != nil {
return ctrl.Result{}, err
}
if err := r.Create(ctx, dep); err != nil {
return ctrl.Result{}, err
}
logger.Info("created Deployment", "name", dep.Name)
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
}
if err != nil {
return ctrl.Result{}, err
}
gb.Status.Ready = dep.Status.ReadyReplicas >= gb.Spec.FrontendSize
if err := r.Status().Update(ctx, &gb); err != nil {
return ctrl.Result{}, err
}
if !gb.Status.Ready {
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
return ctrl.Result{}, nil
}
func (r *GuestbookReconciler) desiredDeployment(gb *webappv1.Guestbook) *appsv1.Deployment {
replicas := gb.Spec.FrontendSize
return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: gb.Name + "-frontend", Namespace: gb.Namespace},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": gb.Name}},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": gb.Name}},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "frontend",
Image: "nginx:1.27",
}},
},
},
},
}
}
func (r *GuestbookReconciler) teardown(ctx context.Context, gb *webappv1.Guestbook) error {
return nil // delete external DB rows, revoke IAM, etc.
}What this demonstrates:
RequeueAfter while waiting for Deployment readinessctrl.Request{NamespacedName} entries; it does not pass the object body.Get because caches and other controllers may have changed objects mid-flight.error requeues with exponential backoff unless you use RequeueAfter with nil error.SetupWithManager connects For(&Guestbook{}) so spec/status changes enqueue the parent.| Return | Effect |
|---|---|
ctrl.Result{}, nil | Success; requeue only on future watch events |
ctrl.Result{Requeue: true}, nil | Immediate requeue |
ctrl.Result{RequeueAfter: d}, nil | Delayed requeue without error metric |
ctrl.Result{}, err | Error logged; backoff requeue |
// Patch status conditions without clobbering other fields
meta.SetStatusCondition(&gb.Status.Conditions, metav1.Condition{
Type: "Ready",
Status: metav1.ConditionTrue,
Reason: "DeploymentReady",
Message: "frontend replicas ready",
})
// Aggregate multiple errors from child ensures
return ctrl.Result{}, errors.Join(errDeploy, errSvc)spec solely via defaulting webhooks or user edits.client.IgnoreNotFound(err) on Get.Update instead of Status().Update - RBAC failures or spec conflicts. Fix: use status subresource client.RequeueAfter with sane caps and surface Degraded conditions.SetControllerReference before Create.| Alternative | Use When | Don't Use When |
|---|---|---|
| controller-runtime Reconciler | Typed kubebuilder operators | You only need a single informer callback |
| Raw workqueue + informer handlers | Fine-grained queue control | You want status helpers and builder pattern |
| Kopf / Java reconcile annotations | Non-Go stacks | You need controller-gen and envtest |
| Job per CR event | Rare batch transforms | Continuous convergence is required |
NamespacedName with Namespace and Name fields.
Fetch the full object yourself with r.Get.
Use server-side apply or strategic merge patches when you own only part of a shared object.
For owned Deployments you fully control, compare spec and Update or Patch idempotently.
Return error for unexpected API failures worth alerting on.
Use RequeueAfter for expected waiting (Deployment not ready yet).
Register Owns(&appsv1.Deployment{}) in SetupWithManager so Deployment changes enqueue the owning Guestbook.
Each pass reads the current spec and full cluster state, then converges.
It does not assume you saw every individual event that caused drift.
Keep reconcile fast; delegate long tasks to Jobs or goroutines with careful status tracking.
Long reconcile blocks worker threads and delays other objects.
Use envtest or a fake client, call Reconcile with a constructed ctrl.Request, assert on child objects and status.
Pass ctx to all client calls.
Manager cancellation on shutdown should stop outbound API calls promptly.
Use predicates to filter noisy status updates, not to skip necessary level-driven passes on spec changes.
Default parallelism is usually sufficient.
Raise MaxConcurrentReconciles only when profiling shows queue backlog and API limits allow it.
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).
Reviewed by Chris St. John·Last updated Jul 16, 2026