Back to blog

Kubernetes Controller vs Operator: What's Actually the Difference?

kubernetes
controller
operator
golang
SRE
PJ
Pratik Jadhav·August 13, 2026·11 min read
Kubernetes Controller vs Operator: What's Actually the Difference?
On this page

If you have spent time around Kubernetes, you have probably heard the words Controller and Operator used almost interchangeably.

A Controller watches resources and reacts to changes. An Operator also watches resources and reacts to changes. Both use reconciliation loops. Both can automate complex work inside a cluster.

So what is the actual difference?

I was confused about this too. For a while, I assumed that a custom Controller becomes an Operator once its automation is advanced enough. That sounds reasonable, but it is not how the distinction works.

The difference is not about code size or complexity. It is about the API being exposed, the resources being managed, and the operational knowledge encoded in the system.

I understood this more clearly after working on both sides. I built a Kubernetes Controller called IRIS and also worked on Parseable Auto Instrumentation, which follows the Operator pattern. We will get to both examples, but first we need to understand the small idea powering all of Kubernetes.

First, Forget Kubernetes for a Minute

Imagine a thermostat.

You set the desired temperature to 24°C. The room is currently at 20°C. The thermostat measures the difference and turns on the heater. It keeps checking until reality matches your setting.

That is a control loop:

Desired state: 24°C
Current state: 20°C
Difference:     +4°C
Action:         Turn on heater
Repeat

Kubernetes is built from loops like this.

When you create a Deployment with three replicas, you do not command Kubernetes to “start Pod 1, then Pod 2, then Pod 3.” You declare the state you want:

spec:
  replicas: 3

A controller continuously compares that desired state with the current state. If only two Pods exist, it works to create another one. If a Pod disappears later, the loop runs again.

This repeated movement from actual state toward desired state is called reconciliation. The official Kubernetes controller documentation describes controllers as control loops that watch cluster state and make or request changes.

So What Exactly Is a Controller?

A controller is a Kubernetes API client with a loop.

In practice, it usually does four things:

  1. Watch a resource for changes.
  2. Read its desired and current state.
  3. Act when the two do not match.
  4. Requeue and check again later.

The simplified shape looks like this:

func Reconcile(ctx context.Context, request Request) error {
    object := getLatestState(request)
 
    if stateIsAlreadyCorrect(object) {
        return nil
    }
 
    makeRealityMatchDesiredState(object)
    return nil
}

Real reconciliation has retries, conflict handling, timeouts, status updates, and idempotency concerns. But this small loop is the heart of it.

Kubernetes already ships with many controllers. Deployment, ReplicaSet, Job, Node, and namespace behavior all depend on controllers. Writing a custom controller means adding another control loop without changing Kubernetes itself.

One important property: reconciliation is level-based, not just event-based.

An event may wake the controller, but the event should not be its only source of truth. Events can be duplicated or missed. A reliable controller reads the latest state from the API and decides what must be true now.

Where an Operator Enters the Picture

An Operator is not a replacement for a controller. It is a pattern built using one or more controllers.

The difference is domain knowledge.

Suppose a team runs PostgreSQL manually. An experienced database operator knows how to create an instance, add replicas, take and restore backups, handle failover, and upgrade it without corrupting data.

A Kubernetes Operator captures that operational knowledge in code. It commonly introduces a Custom Resource Definition (CRD), perhaps PostgresCluster, and reconciles instances of that resource.

apiVersion: database.example.io/v1
kind: PostgresCluster
metadata:
  name: payments-db
spec:
  version: "17"
  replicas: 3
  backupSchedule: "0 2 * * *"

The user declares what database they want. The Operator knows how to create StatefulSets, Services, Secrets, backups, upgrades, and recovery workflows.

Kubernetes describes Operators as software extensions that use custom resources to manage applications and their components. A custom resource plus a custom controller gives users a declarative API instead of a collection of scripts and manual runbooks.

The relationship is:

Controller = watch + reconcile
 
Operator   = controller(s)
           + custom resource(s)
           + application-specific operational knowledge

Every Operator contains controller logic. Not every controller is an Operator.

Controller vs Operator Without the Marketing

Here is the mental model I now use.

A controller answers:

“How do I keep this resource or cluster condition in the state it should be?”

An Operator answers:

“How would a skilled human operate this specific application throughout its lifecycle, and how can I expose that as a Kubernetes API?”

A controller can watch built-in resources such as Deployments and Pods. It does not need a CRD.

An Operator normally owns a domain-specific API such as KafkaCluster, RedisFailover, or Certificate. Users create that custom resource, and the Operator turns the declaration into lower-level Kubernetes and external resources.

This is why code size, language, or complexity cannot decide the label. A 20,000-line program that watches Deployments is not automatically an Operator. A smaller controller paired with a well-designed custom API may follow the Operator pattern perfectly.

A Real Operator I Worked On: PAI

IRIS taught me how a custom controller reacts to built in Kubernetes resources. While working at Parseable, I also got to work on Parseable Auto Instrumentation (PAI). That showed me the other side of the idea through a real Operator built around a domain specific custom resource.

PAI solves a common observability problem. Getting logs, metrics, traces, and Kubernetes events into an observability backend often means maintaining several OpenTelemetry Collector configurations, instrumentation resources, exporters, headers, and namespace filters. The setup works, but it can become a lot of configuration before useful telemetry reaches the backend.

With PAI, a user declares that intent through one ParseableConfig custom resource:

apiVersion: observability.parseable.com/v1alpha1
kind: ParseableConfig
metadata:
  name: production
  namespace: pai-system
spec:
  target:
    endpoint: https://your-parseable-endpoint
    credentialsSecret:
      name: parseable-creds
      namespace: pai-system
  logs:
    targetDataset: app-logs
  traces:
    targetDataset: app-traces
    instrumentation:
      languages: [java, python, nodejs, dotnet]
  events:
    enabled: true
    targetDataset: kubernetes-events

The PAI Operator watches that CR and manages the lower-level resources needed to make it real:

ParseableConfig CR

     PAI Operator

OpenTelemetry Collectors + Instrumentation CR + PAI Agent

Logs + Metrics + Traces + Events

      Parseable

This is the Operator pattern in practice. The user says what telemetry should be collected and where it should go. PAI contains the Parseable and OpenTelemetry knowledge required to decide how to configure collectors, inject SDKs, route signals, and clean everything up when the custom resource is deleted.

What I like about PAI is the abstraction. One ParseableConfig can describe the complete observability setup while the Operator manages the moving parts. Teams keep control over signals and namespaces without repeating the same collector configuration across the cluster. It is a good example of an Operator removing operational work instead of adding another layer only for the sake of Kubernetes.

If you use Parseable, or if you want a simpler path to Kubernetes observability with OpenTelemetry, the PAI auto instrumentation guide is worth checking out. It brings cluster logs, pod and node metrics, distributed traces, and Kubernetes events into Parseable, with namespace selection through the ParseableConfig API.

Why IRIS Is a Controller Today

IRIS watches existing Kubernetes Deployments. A team opts a Deployment into IRIS by adding an annotation:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: receipt-api
  annotations:
    iris.argoproj.io/app: "receipt-api"

IRIS does not currently define an IrisPolicy, RollbackPolicy, or ManagedApplication custom resource. Its configuration is not a new declarative Kubernetes API. It reacts to the state of a built-in resource and coordinates external systems.

Its loop is roughly:

Deployment changes

Is it managed by IRIS?

Is rollout unhealthy?

Collect Kubernetes events + Prometheus metrics + Loki logs

Detect CrashLoopBackOff or run incident analysis

Rollback justified?

Trigger ArgoCD rollback

Apply cooldown and reconcile again

That is a custom controller doing meaningful automation. It crosses multiple systems, but the Kubernetes side of its contract is still a watched Deployment plus an annotation.

Calling it a controller is more precise than calling it an Operator. Precision matters, especially when the project itself is meant to demonstrate Kubernetes knowledge.

Could IRIS Become an Operator?

Yes, but adding a CRD only to earn the name would be bad design.

A custom resource becomes useful when users need a stable, declarative way to express incident policy. For example:

apiVersion: iris.pratikjadhav.me/v1alpha1
kind: RollbackPolicy
metadata:
  name: receipt-api-policy
spec:
  targetRef:
    kind: Deployment
    name: receipt-api
  argoApplication: receipt-api
  autoRollback:
    enabled: true
    riskThreshold: 0.7
    cooldown: 5m
  signals:
    maxRestartCount: 3
    errorRateThreshold: 5
  notifications:
    slackChannel: "#incidents"

Now Kubernetes users can run:

kubectl get rollbackpolicies
kubectl describe rollbackpolicy receipt-api-policy

The controller could update .status with conditions that other tools understand:

status:
  phase: Healthy
  lastAnalysisTime: "2026-08-08T10:30:00Z"
  lastStableRevision: "a1b2c3d"
  conditions:
    - type: RollbackReady
      status: "True"
      reason: StableRevisionFound

At that point, IRIS would not merely react to Deployments. It would own an incident-recovery API and encode an SRE's operational decisions behind it. That is a strong fit for the Operator pattern.

But a CRD also creates responsibilities: API versioning, validation, defaults, status semantics, upgrades, RBAC, finalizers, and backward compatibility. The Kubernetes custom resource guide makes the trade-off clear: add a custom resource when the problem naturally fits a declarative API, not because CRDs look impressive in an architecture diagram.

What Building IRIS Taught Me About Reconciliation

Understanding the definition was useful. Building the loop exposed the harder lessons.

1. A controller must be idempotent

Kubernetes may call reconciliation many times for the same state. Running the same logic twice should not trigger two rollbacks or corrupt state.

Before acting, IRIS needs to check whether the Deployment is still failed, whether a rollback has already started, whether a stable ArgoCD revision is available, and whether the application is inside its cooldown window.

The goal is not “process this event once.” The goal is “make the current state correct, no matter how many times this loop runs.”

2. External APIs make the loop harder

Kubernetes API state is only part of IRIS. Prometheus, Loki, an AI service, and ArgoCD can each be slow or unavailable.

A controller cannot freeze forever because one dependency timed out. Each call needs a timeout, errors need classification, and temporary failures need safe retries. Most importantly, loss of observability data should not silently become permission to take a destructive action.

3. Automation needs guardrails

Automatic recovery sounds great until bad logic creates a rollback loop.

IRIS uses a cooldown to avoid repeated rollback attempts during an unstable rollout. A production version also needs explicit policy, auditability, last-known-good revision checks, and a safe manual path when evidence is weak.

“Can automate” and “should automate now” are different decisions.

4. Status is part of the product

Logs help the developer of a controller. Status helps its users and other automation.

This is another reason a future IRIS custom resource is attractive. A clear .status could show why analysis ran, which evidence was available, why rollback was approved or rejected, and what revision is now active.

If users must search raw controller logs to learn what happened, the API is not finished.

One Final Definition

If you remember only one thing, remember this:

A controller reconciles state. An Operator uses controllers to encode how a specific application should be operated.

IRIS today is a Kubernetes controller. It watches Deployments, diagnoses failures with data from multiple systems, and requests recovery through ArgoCD.

Its possible next step is an Operator: a version where users declare recovery intent through a custom Kubernetes resource and IRIS manages that policy through its lifecycle.

Building the controller first was not a lesser approach. It forced me to understand the real primitive before adding another abstraction.

And that was the part tutorials had not made clear: Operator is the pattern. Reconciliation is the engine.

IRIS is under active development. You can follow the code and upcoming CRD design on GitHub. If you are building Kubernetes controllers or Operators, connect with me on LinkedIn.

More from Pratik