A Deployment restarts its pods indefinitely: exactly the behavior wanted for an HTTP server, and exactly the behavior to avoid for a database migration that needs to run once and stop. Job answers that structurally different need: guaranteeing a task completes successfully, not that it stays alive.

A Job completes, never stays alive

A pod managed by a Job has its restartPolicy set to OnFailure or Never, never Always: once the container exits with status code 0, the Job is marked complete and nothing gets relaunched.

apiVersion: batch/v1
kind: Job
metadata:
  name: db-migration
spec:
  backoffLimit: 3
  template:
    spec:
      restartPolicy: OnFailure
      containers:
        - name: migrate
          image: migrate-tool:1.2
          command: ["migrate", "up"]

backoffLimit caps the number of attempts before the Job is marked definitively failed, with a growing delay between each try (the same backoff mechanism that produces a CrashLoopBackOff on a pod managed differently). That’s exactly the mechanism protecting a database migration deployed as a Helm hook: three consecutive failures stop the attempt rather than looping indefinitely on a broken migration.

Completions and parallelism: a batch, not just a single task

A Job can run several instances of the same task, sequentially or in parallel, useful for batch processing.

spec:
  completions: 10
  parallelism: 3

completions: 10 requires ten total successful runs; parallelism: 3 allows at most three simultaneously. Without these fields, a Job runs a single instance by default, the most common case (a migration, an initialization script).

CronJob: a Job created on a schedule

CronJob doesn’t replace Job, it creates a new one on every trigger, following classic cron syntax.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-report
spec:
  schedule: "0 2 * * *"
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: report
              image: report-generator:2.0

concurrencyPolicy decides what happens if a previous run is still going when the next one should start: Forbid skips the next run rather than launching a second one in parallel, Replace kills the old one to start the new one, Allow (the default) lets both coexist, rarely what you want for a task touching the same data. A CronJob never waits for an external signal: if it misses its slot (node unavailable, cluster under maintenance), the default behavior is to skip the missed run, not automatically catch it up.

The cleanup everyone forgets

By default, finished Jobs (and the pods they created) never delete themselves automatically, which accumulates Completed objects cluttering kubectl get pods over time, especially for a CronJob creating a new one on every run.

spec:
  ttlSecondsAfterFinished: 86400

ttlSecondsAfterFinished automatically deletes the Job (and its pods) a given delay after it finishes, successfully or not. Without this field, cleanup stays manual, an easily forgotten detail that eventually turns a namespace into a museum of Jobs finished months ago.

Takeaway

Job guarantees a task completes, with retry bounded by backoffLimit, unlike a Deployment that restarts indefinitely. completions/parallelism handle a batch of runs rather than a single one. CronJob creates Jobs on a schedule, never guaranteeing automatic catch-up of a missed run, and concurrencyPolicy decides overlap behavior. ttlSecondsAfterFinished avoids the silent accumulation of finished Jobs, a cleanliness detail that matters the moment a CI/CD pipeline generates them regularly.