Kubernetes Controller vs Operator: What Actually Separates Them

“Is this a controller or an operator?” comes up in every platform team sooner or later, usually when someone proposes writing one. The honest answer is that every operator is a controller, but not every controller is an operator. The line between them is not technical. It is about what the loop knows.

A controller is a loop that watches Kubernetes objects and moves the real state of the cluster toward the desired state written in those objects. An operator is a controller that manages a specific application through its own custom resources and carries the operational knowledge a human operator would otherwise have: how to install it, upgrade it, back it up, fail it over and recover it.

This article goes through the definitions precisely, compares the two in a table, shows what a minimal reconcile loop looks like, covers the frameworks you would use in 2026, and, maybe most usefully, lists the cases where you should build neither. If you want the gentler introduction to the operator pattern first, start with Kubernetes operators: the basics and come back.

What a Kubernetes Controller Is

Kubernetes is built on one idea: you declare the state you want, and something keeps making it true. That “something” is a controller.

A controller runs a reconciliation loop:

  1. Watch one or more resource types through the API server.
  2. When something changes, read the desired state (the object’s spec).
  3. Observe the actual state (other objects, or the outside world).
  4. Act to close the gap: create, update or delete things.
  5. Write what it observed into the object’s status.
  6. Repeat, forever.

Two properties make this model work.

Controllers are level-triggered, not edge-triggered. A controller does not react to “a pod was deleted” as an event to process exactly once. It reacts to “the world may have changed, go look”. If it misses an event, restarts, or receives five changes at once, the next reconcile still computes the right answer, because it compares full desired state against full actual state. This is why reconcile functions must be idempotent: running them twice in a row must be harmless.

Controllers own a narrow slice of the world. The Deployment controller manages ReplicaSets. The ReplicaSet controller manages Pods. The scheduler assigns Pods to nodes. The kubelet runs containers. None of them does the others’ job; the behavior you see when you kubectl apply a Deployment is the emergent result of several small loops cooperating through the API server.

The built-in controllers

Most of the controllers you use every day live in a single binary, kube-controller-manager, which runs on the control plane. It bundles dozens of loops, among them:

  • Deployment and ReplicaSet controllers, which roll out and maintain pod replicas.
  • StatefulSet, DaemonSet, Job and CronJob controllers.
  • The EndpointSlice controller, which keeps Service backends up to date.
  • The Namespace controller, which cleans up everything when a namespace is deleted.
  • The garbage collector, which deletes dependents through ownerReferences.
  • The node lifecycle controller, which marks nodes unhealthy and evicts pods.
  • The horizontal pod autoscaler controller, which adjusts replica counts from metrics (see Kubernetes HPA best practices for how that loop behaves).
  • The PersistentVolume binder and expansion controllers.

Cloud-specific loops (load balancers, node addresses, routes) run separately in the cloud-controller-manager. And plenty of third-party components are controllers too: ingress controllers, the ArgoCD application controller, external-dns, cluster-autoscaler, Karpenter.

None of those is usually called an operator. They are generic infrastructure: they manage Kubernetes primitives or a single external concern, without encoding how to run a particular piece of software.

What a Kubernetes Operator Is

The term “operator” was coined by CoreOS in 2016 to describe a specific use of the controller pattern: putting the knowledge of a human operator into software. The official Kubernetes documentation defines operators as “software extensions to Kubernetes that make use of custom resources to manage applications and their components”.

An operator has three ingredients:

  1. A Custom Resource Definition (CRD) that models the application in its own vocabulary. You do not create a StatefulSet, a Service, a ConfigMap, a PodDisruptionBudget and a CronJob for backups. You create a PostgresCluster or a Kafka object with fields like instances, version and backup.schedule.
  2. A controller that reconciles those custom resources into the underlying Kubernetes objects and external state.
  3. Domain knowledge baked into that controller: the day-2 operations that are specific to the application. Promoting a replica when the primary dies. Running a schema migration before rolling out a new version. Rebalancing partitions after scaling a broker set. Taking a consistent backup. Rotating certificates without downtime.

The third ingredient is the one that matters. A controller that turns a WebApp custom resource into a Deployment plus a Service has a CRD and a reconcile loop, so it technically fits the definition, but it knows nothing a Helm chart does not know. A PostgreSQL operator that notices the primary is unreachable, picks the most up-to-date replica, promotes it, re-points the read-write Service and re-attaches the old primary as a replica when it comes back is doing something no template can do.

Controller vs Operator: Side-by-Side Comparison

AspectControllerOperator
Core mechanismReconciliation loopReconciliation loop (an operator is a controller)
Resources it watchesBuilt-in types (Pods, Deployments, Services, Nodes) or any typeIts own custom resources, plus the built-in types it creates
Needs a CRDNoYes, by definition
ScopeOne generic concern (replicas, endpoints, DNS records, nodes)One specific application or system (PostgreSQL, Kafka, Prometheus)
Knowledge encodedHow a Kubernetes primitive should behaveHow to install, upgrade, back up, heal and scale a particular piece of software
Typical authorKubernetes project, infrastructure vendorsThe application’s vendor or community, or a platform team
Where it runsOften in kube-controller-manager, or as a standalone DeploymentAlways a standalone Deployment in the cluster
Lifecycle coverageUsually “keep N of X running”Day 1 (install) and day 2 (upgrade, backup, failover, tuning)
ExamplesDeployment, ReplicaSet, EndpointSlice, HPA, ingress-nginx, external-dnsPrometheus Operator, CloudNativePG, Strimzi, cert-manager (borderline)

In one sentence: the controller is the pattern, the operator is a use of the pattern for managing an application with custom resources and embedded operational expertise.

The Grey Zone: Is cert-manager an Operator?

The boundary is fuzzy, and arguing about it is mostly unproductive, but a few examples help calibrate.

cert-manager introduces CRDs (Certificate, Issuer, ClusterIssuer, CertificateRequest) and reconciles them. It encodes real domain knowledge: the ACME protocol, HTTP-01 and DNS-01 challenges, renewal windows, key rotation. Yet its own documentation calls it a controller, and it does not manage an application in the “install and run this database” sense. It manages a concern: certificates. Most people would call it an operator anyway, and that is fine.

ArgoCD has CRDs (Application, ApplicationSet, AppProject) and an application controller. It is a controller for GitOps delivery, not an operator for a specific workload. There is, however, an ArgoCD Operator that installs and upgrades ArgoCD itself, which shows how the word tends to be used: “the thing that runs X for you”. (More on ArgoCD itself in the ArgoCD guide.)

A custom WebApp CRD that expands into a Deployment, Service and Ingress is a controller with a CRD. Calling it an operator is not wrong, but it is an operator with capability level 1, which leads to the next section.

Operator Capability Levels

The Operator Framework defines a five-level maturity model that is the most useful way to tell a “real” operator from a templating controller:

LevelNameWhat the operator does
1Basic InstallProvisions the application from the custom resource and reports status
2Seamless UpgradesUpgrades the application (and itself) between versions, including data or schema steps
3Full LifecycleBackup and restore, failure recovery, scaling, reconfiguration
4Deep InsightsExposes metrics, alerts and logs about the managed application, surfaces health in status
5Auto PilotAuto-scaling, auto-tuning, anomaly detection and self-healing without human input

A level 1 operator does roughly what helm install does. The reason operators exist is levels 2 and 3. If your design stops at level 1, you probably want a Helm chart, not an operator.

A Minimal Reconcile Loop in Go

The most common way to write a controller or operator in Go is controller-runtime, the library under Kubebuilder and Operator SDK. Here is the shape of a reconcile function for a hypothetical Website custom resource that owns a Deployment:

func (r *WebsiteReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    log := log.FromContext(ctx)

    // 1. Read the desired state.
    var site webv1.Website
    if err := r.Get(ctx, req.NamespacedName, &site); err != nil {
        // Deleted between the event and now: nothing to do.
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    // 2. Build the object we want to exist.
    deploy := &appsv1.Deployment{
        ObjectMeta: metav1.ObjectMeta{Name: site.Name, Namespace: site.Namespace},
    }

    // 3. Create or update it idempotently, owned by the Website.
    op, err := controllerutil.CreateOrUpdate(ctx, r.Client, deploy, func() error {
        deploy.Spec.Replicas = ptr.To(site.Spec.Replicas)
        deploy.Spec.Selector = &metav1.LabelSelector{MatchLabels: map[string]string{"app": site.Name}}
        deploy.Spec.Template.Labels = map[string]string{"app": site.Name}
        deploy.Spec.Template.Spec.Containers = []corev1.Container{{
            Name:  "web",
            Image: site.Spec.Image,
        }}
        return controllerutil.SetControllerReference(&site, deploy, r.Scheme)
    })
    if err != nil {
        return ctrl.Result{}, err // controller-runtime requeues with backoff
    }
    log.Info("deployment reconciled", "operation", op)

    // 4. Report observed state.
    site.Status.ReadyReplicas = deploy.Status.ReadyReplicas
    site.Status.ObservedGeneration = site.Generation
    if err := r.Status().Update(ctx, &site); err != nil {
        return ctrl.Result{}, err
    }
    return ctrl.Result{}, nil
}

func (r *WebsiteReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&webv1.Website{}).     // reconcile when a Website changes
        Owns(&appsv1.Deployment{}). // ...or when a Deployment it owns changes
        Complete(r)
}

Notice what the function does not do. It never asks “what changed?”. It reads the whole desired state, computes the whole target object and lets CreateOrUpdate make it so. The owner reference means that deleting the Website garbage-collects the Deployment, and Owns() means a manual edit to the Deployment triggers a reconcile that puts it back.

This is a controller with a CRD. To turn it into an operator worth the name, the reconcile function would grow the domain logic: detecting that spec.version moved from 3 to 4 and running a migration Job first, holding the rollout until the Job succeeds, setting a Degraded condition if it fails, taking a backup before any destructive change. That logic, not the scaffolding, is where the months of work go.

A few mechanics every non-trivial operator ends up needing:

  • Finalizers, so the controller can clean up external resources (a cloud bucket, a DNS record, a database user) before the custom resource disappears.
  • Status conditions following the standard type/status/reason/message shape, plus observedGeneration, so users and tools can tell whether the latest spec has been processed.
  • Requeue with a delay (ctrl.Result{RequeueAfter: ...}) for things that must be polled, such as an external API that does not emit Kubernetes events.
  • Leader election, so only one replica of the operator acts at a time.
  • Narrow RBAC and namespace-scoped watches where possible, because an operator is a highly privileged component.

Frameworks for Writing Controllers and Operators in 2026

You almost never write the watch, cache and work-queue machinery yourself. The main options, all actively maintained as of September 2026:

controller-runtime and Kubebuilder (Go)

controller-runtime (kubernetes-sigs, v0.25 at the time of writing) is the library: manager, cached client, informers, work queues, leader election, webhooks. Kubebuilder (v4.16) is the scaffolding and code generator on top: it creates the project layout, CRD types with markers, RBAC manifests, webhooks and an envtest-based test setup. If you write Go and have no special constraints, this is the default choice, and the one most operators in the CNCF landscape use.

Operator SDK (Go, Helm, Ansible)

Operator SDK (Operator Framework, v1.42) uses Kubebuilder’s plugins for Go projects, so the Go experience is nearly identical. What it adds is integration with the Operator Lifecycle Manager (OLM) for packaging and distributing operators as bundles, a scorecard for testing against best practices, and two non-Go flavors: Helm-based operators, which reconcile a custom resource by rendering a chart, and Ansible-based operators, which run playbooks. Choose it if you target OpenShift or OperatorHub, or if your team would rather write Ansible than Go.

Java Operator SDK (Java)

Java Operator SDK (v5.6, released September 2026) brings the same model to the JVM, with a Quarkus extension that produces native images. Recent releases added informer sharing between controllers and a built-in event recorder. It is the natural fit for teams whose domain logic and client libraries already live in Java; Strimzi and Keycloak’s operators are prominent JVM examples of the pattern.

kopf (Python)

kopf (Kubernetes Operator Pythonic Framework, 1.44) lets you write handlers as decorated Python functions, such as @kopf.on.create('websites'). It is excellent for glue operators, prototypes and teams of SREs who live in Python. It is less common for large, performance-sensitive operators, but a lot of internal automation runs happily on it.

Metacontroller (any language, via webhooks)

Metacontroller (v4.17) inverts the model: it runs the watch-and-reconcile machinery for you and calls your webhook with the parent object and its current children, and your webhook returns the desired children. Your “controller” is a stateless HTTP function in any language. It is ideal for simple composite resources and decorators, and a poor fit for operators with complex multi-step state machines.

Declarative alternatives: kro and Crossplane

If what you want is “one custom resource that expands into a bundle of other resources”, you may not need to write a controller at all. kro (Kube Resource Orchestrator, a Kubernetes SIG Cloud Provider subproject) lets you define a ResourceGraphDefinition that generates a new CRD and its reconciliation from a declarative graph; note that its APIs are still pre-1.0 and not declared production-ready. Crossplane compositions do something similar, with a focus on cloud infrastructure. Both give you a level 1 “operator” without Go code.

When to Write a Controller, an Operator, or Neither

This is the decision that matters, and the default answer should be “neither”.

Use a Helm chart (or Kustomize) when the application is stateless or its state is managed elsewhere, installation is the hard part, and upgrades are “roll out a new image”. Templates plus helm upgrade plus a sane rollout strategy cover the vast majority of internal services. Chart hooks handle the occasional pre-upgrade migration (see the Helm hooks guide), and there are well-known patterns for organizing charts across many services (centralized vs per-service chart management).

Use an existing operator when you are running well-known stateful software. PostgreSQL, Kafka, Redis, Elasticsearch, RabbitMQ and Prometheus all have mature operators whose authors have already hit the failure modes you have not. Writing your own PostgreSQL operator to avoid learning CloudNativePG is a multi-year mistake.

Write a plain controller when you need to keep something continuously in sync and no existing tool does it: labeling namespaces from an external CMDB, creating DNS records or cloud IAM bindings from annotations, enforcing a cleanup policy. There may be no CRD at all; the controller can watch built-in resources and act on annotations.

Write an operator when all of these are true:

  • The software has stateful day-2 operations that humans currently perform by runbook: failover, resharding, ordered upgrades, backup and restore.
  • Those operations are frequent or risky enough that automating them pays back the investment.
  • No mature operator already exists, or the existing ones are unmaintained or cannot be adapted.
  • You have the team to own it long-term. An operator is production software with its own release cycle, API versioning, conversion webhooks and CVEs.

Real-World Operators Worth Studying

Reading good operators is the fastest way to understand the gap between a controller and an operator.

Prometheus Operator manages Prometheus and Alertmanager through CRDs such as Prometheus, Alertmanager, ServiceMonitor, PodMonitor and PrometheusRule. Its key insight is turning scrape configuration into Kubernetes objects that each team can own next to its application; the operator regenerates Prometheus configuration and reloads it. If you have used kube-prometheus-stack, you have used it. The distinction between its two main discovery CRDs is covered in ServiceMonitor vs PodMonitor.

CloudNativePG runs PostgreSQL with a Cluster CRD and manages primary and replica pods directly rather than through a StatefulSet, precisely because it needs finer control over which instance is primary, how failover happens and how volumes are handled. It covers switchover, automated failover, continuous backup and point-in-time recovery to object storage, and rolling minor-version upgrades. It is a textbook level 3+ operator.

Strimzi runs Apache Kafka with a cluster operator plus topic and user operators, modeling brokers, node pools, topics, users and connectors as custom resources. It handles rolling restarts in the order Kafka needs, certificate management between brokers and clients, and scaling with partition reassignment through Cruise Control.

cert-manager, discussed above, is the canonical example of a controller-shaped project with deep domain knowledge of a protocol rather than of a workload.

The Logging Operator is another good example of a CRD-driven design where teams declare log flows and outputs per namespace, and the operator manages the Fluent Bit and Fluentd deployments behind them.

Anti-Patterns

The common ways operators go wrong:

An operator that just runs Helm. A controller whose reconcile function renders a chart and applies it adds a CRD, a Deployment, RBAC and a new failure mode, and gives you nothing helm upgrade in a GitOps pipeline would not. If there is no day-2 logic, delete it.

Edge-triggered thinking. Writing reconcile as “on create do A, on update do B” leads to drift the first time an event is missed. Always compute from the full desired state.

Non-idempotent side effects. Sending an email, charging a card or creating a cloud resource without first checking whether it already exists will happen twice. Record what you did in status or use deterministic names.

No status. An operator that never writes conditions leaves users running kubectl logs on the operator pod to guess what happened. Status is the operator’s user interface.

Cluster-admin RBAC. Operators are high-value targets. Request only the verbs and resources you use, and prefer namespace-scoped installs where the software allows it.

Hot requeue loops. Returning an error or Requeue: true on every reconcile for a condition that will not change for minutes hammers the API server. Use RequeueAfter with a sensible interval.

Blocking finalizers. A finalizer that can never complete (because the external system is gone) leaves objects stuck in Terminating forever. Always have a timeout or an escape hatch.

CRD versioning as an afterthought. Once users have objects stored as v1alpha1, changing the schema means conversion webhooks and storage migration. Design the API as carefully as a public REST API, because it is one.

Frequently Asked Questions

What is the difference between a Kubernetes controller and an operator?

A controller is any loop that watches Kubernetes resources and reconciles actual state toward desired state; the Deployment and ReplicaSet controllers are examples. An operator is a controller that manages a specific application through its own Custom Resource Definitions and embeds the operational knowledge needed to run it: upgrades, backups, failover and recovery. Every operator is a controller, but only controllers that combine custom resources with application-specific day-2 logic are usually called operators.

Is every operator a controller?

Yes. An operator is implemented as one or more controllers running a reconciliation loop over custom resources. The difference is not in the mechanism but in the scope and knowledge: operators manage an application’s full lifecycle, while generic controllers manage Kubernetes primitives or a single infrastructure concern.

Does a controller need a CRD?

No. Many controllers only watch built-in resources: the Deployment, EndpointSlice and garbage collector controllers in kube-controller-manager, or an external controller that reacts to annotations on Services. A CRD is only required when you need a new resource type to express desired state, which is always the case for operators.

Should I use a Helm chart or an operator?

Use a Helm chart when installation and configuration are the main challenge and upgrades are simple rollouts, which covers most stateless services. Use an operator when the software needs automated day-2 operations such as failover, ordered upgrades with data migration, or backup and restore. Operators often install via Helm themselves, so the two are complementary rather than exclusive.

Which framework should I use to write a Kubernetes operator?

For Go, use Kubebuilder on top of controller-runtime; choose Operator SDK instead if you need OLM packaging for OpenShift or OperatorHub, or want Helm- or Ansible-based operators. For Java, use Java Operator SDK, and for Python use kopf. For simple composite resources in any language, Metacontroller or a declarative tool like kro can avoid writing a full controller.

What are the Operator Framework capability levels?

The Operator Framework defines five maturity levels: Basic Install, Seamless Upgrades, Full Lifecycle (backup, recovery, scaling), Deep Insights (metrics, alerts, health reporting) and Auto Pilot (auto-scaling, auto-tuning, self-healing). An operator that only reaches level 1 does roughly what a Helm chart does; the value of an operator appears from level 2 onward.

Where do built-in Kubernetes controllers run?

Most built-in controllers run inside the kube-controller-manager process on the control plane, including the Deployment, ReplicaSet, StatefulSet, Job, Namespace, EndpointSlice, node lifecycle and horizontal pod autoscaler controllers. Cloud-provider-specific controllers run in the cloud-controller-manager, and the scheduler and kubelet are separate components that follow the same watch-and-reconcile model.

Is cert-manager a controller or an operator?

It sits in the grey zone. cert-manager defines CRDs such as Certificate and Issuer and encodes deep domain knowledge about ACME and certificate renewal, so it fits the operator definition, but it manages a cross-cutting concern rather than installing and running an application, and its own documentation describes it as a controller. Both labels are commonly used.

Conclusion

The controller-versus-operator debate is less about definitions than about intent. A controller is the fundamental Kubernetes building block: a level-triggered, idempotent loop that keeps some part of the world matching a declared state. An operator is what you get when you point that building block at one application, give it a vocabulary through custom resources, and teach it everything your on-call engineers know about running that application.

That makes the practical guidance simple. Use Helm for installation, reach for a mature existing operator for well-known stateful systems, write a small controller when you need continuous synchronization that nothing else provides, and write a full operator only when there is real day-2 knowledge to encode and a team ready to own it for years. If you are about to build a level 1 operator, you are probably about to build a Helm chart with extra steps.