Skip to content
BytePatterns

Job vs CronJob vs Deployment in Kubernetes: Finish or Run Forever

9 min readBytePatterns

Job vs CronJob vs Deployment in Kubernetes: a Deployment keeps N Pods running forever, a Job runs Pods until enough succeed, a CronJob makes Jobs on a schedule.

Job vs CronJob vs Deployment in Kubernetes in one line: a Deployment keeps a set number of Pods running forever and replaces any that stop, a Job runs Pods until a set number of them have exited successfully and then stops, and a CronJob creates a new Job on a schedule. The question that picks between them is what a successful exit means. For a web server, a Pod that exits is a failure to be repaired. For a database migration, a Pod that exits with code 0 is the point. Facts come from the Kubernetes documentation listed at the end, as of October 2026.

ControllerGoalA Pod exits with code 0
Deploymentreplicas Pods running, alwaysrestarted; restartPolicy must be Always
Jobcompletions successful exits, then donecounted; restartPolicy Never or OnFailure
CronJobone Job per schedule tickthe Job completes; the next tick makes a new one

The problem it solves

Everything in a cluster is a Pod, but not every Pod has the same life. A server should never stop. A migration should run once, retry if it fails, and then be finished. A nightly backup should start at 02:00, not overlap with yesterday's if that one is still running, and leave a few finished runs around for inspection. Using the wrong controller gives you a migration that reruns forever because the Deployment keeps "repairing" its successful exit, or a server that runs once and is never replaced.

The intuition

A Deployment wants a count. Through its ReplicaSet it keeps replicas Pods matching the selector, and it manages versions of the Pod template so a rollout and a rollback are possible. How those three objects divide the work is in Pod vs ReplicaSet vs Deployment. The point here is the restart policy: a Deployment's Pod template must use restartPolicy: Always, so a container that exits, with any code, is restarted by the kubelet. Success is not a state a Deployment knows about.

A Job wants a number of successes. The documentation describes a Job as creating one or more Pods and continuing to retry execution until a specified number of them successfully terminate. The knobs are:

  • completions: how many successful Pod exits are needed; the default is one.
  • parallelism: how many Pods may run at once; the default is one.
  • backoffLimit: how many failed Pods are tolerated before the Job itself is marked failed; the default is six, and the controller spaces retries with an exponential back-off that the documentation describes as starting at ten seconds, doubling, and capped at six minutes.
  • restartPolicy: Never, so each failure is a new Pod the controller can count, or OnFailure, so the kubelet restarts the container in place.
  • activeDeadlineSeconds: a wall-clock limit for the whole Job, after which it fails regardless of retries.
  • ttlSecondsAfterFinished: how long a finished Job and its Pods stay before the TTL controller deletes them; without it they stay until something removes them.

A Job that has reached completions is Complete. A Job that has exceeded backoffLimit is Failed, with the reason BackoffLimitExceeded. Either way it stops creating Pods.

A CronJob wants Jobs on a timetable. It holds a schedule in cron format and a Job template, and creates a Job for each tick. The documentation says it runs a Job approximately once per execution time and that under some circumstances a single tick may create two Jobs or none, so the work should be idempotent. Its own knobs are about ticks that collide:

  • concurrencyPolicy: Allow lets a new Job start while the previous one runs, and is the default. Forbid skips the new tick if the previous Job has not finished. Replace deletes the running Job and starts the new one.
  • startingDeadlineSeconds: how late a Job may start past its scheduled time and still be started at all. The documentation adds a hard rule for a controller that has been down: if there are more than 100 missed schedules, it does not start the Job and logs an error, and the fix is to set or decrease startingDeadlineSeconds, or to check for clock skew.
  • successfulJobsHistoryLimit and failedJobsHistoryLimit: how many finished Jobs to keep, three and one by default.
  • suspend: pause the schedule without deleting the CronJob.

So the decision is a question about the exit: if a clean exit is a failure, Deployment. If a clean exit is the goal, Job. If that goal recurs on a clock, CronJob.

Watch it run

The lesson animation starts with desired state: a Deployment that wants three replicas of api. The controller creates a ReplicaSet for this version of the template, the ReplicaSet creates three Pods, and a node failure takes one Pod with it. The ReplicaSet sees 2 where 3 are wanted and creates a replacement with a new UID. That observe-compare-act loop is the same in all three controllers; what differs is the comparison. The Deployment compares a running count; a Job compares successful exits against completions; a CronJob compares the clock against its schedule.

Pods, ReplicaSets & Deployments

Step 1 of 10

You write desired state: a Deployment that wants three replicas of api.

The same interactive animation as the lesson — step through it with the controls.

The code

The lesson's Deployment, and the same container as a Job and a CronJob. Note the three restart policies and the fields each kind adds:

# illustrative: one image, three controllers
apiVersion: apps/v1
kind: Deployment
metadata: { name: api }
spec:
  replicas: 3
  selector: { matchLabels: { app: api } }
  template:
    metadata: { labels: { app: api } }
    spec:
      restartPolicy: Always                 # the only value a Deployment accepts
      containers: [{ name: api, image: registry.example.com/api:1.4.2 }]
---
apiVersion: batch/v1
kind: Job
metadata: { name: migrate }
spec:
  completions: 3                            # three successful exits, then Complete
  parallelism: 2                            # at most two Pods at a time
  backoffLimit: 2                           # the third failure fails the Job
  ttlSecondsAfterFinished: 3600             # clean up an hour after it finishes
  template:
    spec:
      restartPolicy: Never                  # Never or OnFailure, not Always
      containers: [{ name: migrate, image: registry.example.com/api:1.4.2, command: ["node", "dist/migrate.js"] }]
---
apiVersion: batch/v1
kind: CronJob
metadata: { name: backup }
spec:
  schedule: "*/10 * * * *"                  # every ten minutes
  concurrencyPolicy: Forbid                 # skip a tick while the last Job runs
  startingDeadlineSeconds: 300              # a tick more than five minutes late is dropped
  successfulJobsHistoryLimit: 3
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers: [{ name: backup, image: registry.example.com/api:1.4.2, command: ["node", "dist/backup.js"] }]

A toy model of how each controller reads a Pod exit, not the Kubernetes controllers. A Deployment restarts any exit. A Job counts exit 0 toward completions and any other code toward backoffLimit, running at most parallelism Pods at a time:

def deployment(replicas, exits):
    """Toy model of a Deployment's view of a Pod exit: whatever the code,
    restartPolicy Always restarts it, because the goal is N running Pods."""
    log = []
    for code in exits:
        log.append(f"pod exited {code} -> restarted ({replicas} running wanted)")
    return log

def job(completions, parallelism, backoff_limit, outcomes):
    """Toy model of a Job controller: run up to `parallelism` Pods at a time,
    count exit 0 toward `completions`, count failures toward `backoffLimit`."""
    outcomes, succeeded, failed, step, log = iter(outcomes), 0, 0, 0, []
    while succeeded < completions:
        running = min(parallelism, completions - succeeded)
        for _ in range(running):
            code = next(outcomes)
            step += 1
            if code == 0:
                succeeded += 1
            else:
                failed += 1
            log.append(f"pod {step} exit {code}: succeeded={succeeded}/{completions} failed={failed}")
            if failed > backoff_limit:
                log.append(f"Job Failed: BackoffLimitExceeded ({failed} > {backoff_limit})")
                return log
    log.append("Job Complete")
    return log

print("\n".join(deployment(3, [0, 1])))
print()
print("\n".join(job(completions=3, parallelism=2, backoff_limit=2, outcomes=[0, 1, 0, 0])))
print()
print("\n".join(job(completions=3, parallelism=2, backoff_limit=2, outcomes=[1, 1, 0, 1, 0])))
# pod exited 0 -> restarted (3 running wanted)
# pod exited 1 -> restarted (3 running wanted)
#
# pod 1 exit 0: succeeded=1/3 failed=0
# pod 2 exit 1: succeeded=1/3 failed=1
# pod 3 exit 0: succeeded=2/3 failed=1
# pod 4 exit 0: succeeded=3/3 failed=1
# Job Complete
#
# pod 1 exit 1: succeeded=0/3 failed=1
# pod 2 exit 1: succeeded=0/3 failed=2
# pod 3 exit 0: succeeded=1/3 failed=2
# pod 4 exit 1: succeeded=1/3 failed=3
# Job Failed: BackoffLimitExceeded (3 > 2)

The Deployment restarted the successful exit as readily as the failed one. The first Job absorbed one failure and completed after four Pods; the second hit its third failure with one completion still missing and stopped for good, which is what backoffLimit is for.

Now the CronJob over a fake clock. A Job is due every ten minutes and each run takes twenty-five, so every tick finds the previous Job still running. The three concurrency policies produce three different timelines:

def cronjob(policy, every=10, duration=25, until=60):
    """Toy model of a CronJob controller over a fake clock: a new Job is due
    every `every` minutes and each run takes `duration` minutes.
    concurrencyPolicy decides what happens while the previous Job still runs."""
    running, log = [], []                      # running: (job name, finish time)
    for now in range(0, until + 1, every):
        running = [(name, end) for name, end in running if end > now]
        name = f"backup-{now:02d}"
        if running and policy == "Forbid":
            log.append(f"t={now:2}: skip {name}, {running[-1][0]} still running")
            continue
        if running and policy == "Replace":
            log.append(f"t={now:2}: delete {running[-1][0]}, start {name}")
            running = []
        else:
            log.append(f"t={now:2}: start {name}" + (f" alongside {len(running)} running" if running else ""))
        running.append((name, now + duration))
    return log

for policy in ("Allow", "Forbid", "Replace"):
    print(policy)
    print("\n".join("  " + line for line in cronjob(policy)))
# Allow
#   t= 0: start backup-00
#   t=10: start backup-10 alongside 1 running
#   t=20: start backup-20 alongside 2 running
#   t=30: start backup-30 alongside 2 running
#   t=40: start backup-40 alongside 2 running
#   t=50: start backup-50 alongside 2 running
#   t=60: start backup-60 alongside 2 running
# Forbid
#   t= 0: start backup-00
#   t=10: skip backup-10, backup-00 still running
#   t=20: skip backup-20, backup-00 still running
#   t=30: start backup-30
#   t=40: skip backup-40, backup-30 still running
#   t=50: skip backup-50, backup-30 still running
#   t=60: start backup-60
# Replace
#   t= 0: start backup-00
#   t=10: delete backup-00, start backup-10
#   t=20: delete backup-10, start backup-20
#   t=30: delete backup-20, start backup-30
#   t=40: delete backup-30, start backup-40
#   t=50: delete backup-40, start backup-50
#   t=60: delete backup-50, start backup-60

Allow piles up three concurrent backups, which is three times the load and a race on the same files. Forbid runs one at a time and drops the ticks in between. Replace never lets a run finish: every tick kills a backup fifteen minutes short of done. For a run longer than its interval, Forbid is the usual answer, and a shorter run is the real fix.

The complexity

The costs are in Pods created and in what is left behind:

  • Deployment: a constant replicas Pods, forever, plus one rollout's worth of extra Pods during an update.
  • Job: at least completions Pods and at most completions + backoffLimit with restartPolicy: Never, each one a scheduling decision and a container start. Finished Pods stay for inspection until ttlSecondsAfterFinished or a delete.
  • CronJob: one Job per tick, bounded by the history limits; a tick with Allow and a slow run can hold several Jobs' worth of Pods at once. The scheduling check itself is cheap: the controller wakes up roughly every ten seconds and compares the clock against each CronJob's schedule.

Where it goes wrong

  • A one-off task as a Deployment. It exits 0 and is restarted, so the migration runs again, and again, with CrashLoopBackOff in between.
  • A server as a Job. It stops serving the moment it exits, and the Job happily reports Complete.
  • restartPolicy: Always in a Job template. The API rejects it; a Job needs Never or OnFailure.
  • Forgetting ttlSecondsAfterFinished. Finished Jobs and their Pods accumulate until someone deletes them.
  • Allow for a run longer than its interval. Runs overlap and compete. Use Forbid, or make the run shorter.
  • A non-idempotent CronJob. The documentation says a tick may run twice or not at all in some circumstances; a job that cannot be rerun safely will eventually be rerun.
  • A long controller outage without startingDeadlineSeconds. Past 100 missed schedules, the controller refuses to start the Job and logs an error instead.

When it shows up in interviews

As a design detail inside a larger answer: "how do you run the schema migration before the new version serves traffic?" or "where does the nightly report run?" The interviewer listens for the exit-code distinction, backoffLimit as the retry budget, and the concurrency policy on a scheduled run. The objects underneath, Pods and the controllers that count them, are in the Pods, ReplicaSets and Deployments lesson; whether the Pods need stable names and their own disks is a separate choice, covered in DaemonSet vs Deployment vs StatefulSet.

How to say it in an interview

"The question is what a clean exit means. For a server, an exit is a failure, so I use a Deployment: it keeps replicas Pods running with restartPolicy: Always and gives me rollouts and rollbacks. For a task that should finish, I use a Job: it creates Pods until completions of them exit 0, runs parallelism at a time, and gives up after backoffLimit failures; the Pod template uses Never or OnFailure, and ttlSecondsAfterFinished cleans up. For a task that recurs, a CronJob creates a Job per schedule tick; I set concurrencyPolicy: Forbid when a run can outlast its interval, keep the work idempotent because a tick can fire twice, and set startingDeadlineSeconds so a long outage does not stall the schedule."

Frequently asked questions

What is the difference between a Job and a Deployment in Kubernetes?

A Deployment keeps a fixed number of Pods running indefinitely and restarts any container that exits, whatever its exit code. A Job runs Pods until a set number of them exit successfully, retries failures up to backoffLimit, and then stops. Use a Deployment for servers and a Job for work that should finish.

What is the difference between a Job and a CronJob?

A Job is a single run of work to completion. A CronJob is a schedule plus a Job template: at each tick it creates a new Job. Its own settings cover collisions and history, such as concurrencyPolicy for overlapping runs, startingDeadlineSeconds for late starts, and how many finished Jobs to keep.

What does concurrencyPolicy do in a CronJob?

It decides what happens when a tick arrives while the previous Job is still running. Allow, the default, starts the new Job alongside it. Forbid skips the new tick. Replace deletes the running Job and starts the new one. For a run that can outlast its interval, Forbid prevents overlapping runs.

Sources