Skip to content
Documentation Background

Jobs & CronJobs

Standard Kubernetes controllers (Deployments, StatefulSets, DaemonSets) are built for continuously running workloads — when a container exits, they restart it. Jobs invert this contract: they manage run-to-completion tasks, ensuring a specified number of pods finish successfully before the Job is considered done. CronJobs wrap Jobs with a scheduling layer, creating new Job objects on a cron schedule.


You can run a finite task with a bare Pod by setting restartPolicy: OnFailure. But:

  • If the node fails, the Pod is gone — Kubernetes won’t reschedule it
  • If the Pod is accidentally deleted, the task is lost
  • You’d have to manually supervise it from start to finish

A Job delegates these responsibilities to the control plane. The Job controller automatically recreates pods on node failures, tracks successful completions, and enforces retry limits.

PropertyBehaviour
Success-driven lifecycleThe Job runs until a specified number of pods complete successfully
Immutable pod templateCannot modify the Pod template after creation — delete and recreate instead
Immediate executionPods start as soon as the Job is created (no native scheduling — use CronJob)
Auto label selectorThe controller generates unique controller-uid and job-name labels — you don’t need to define spec.selector
Completed pods are keptPods aren’t auto-deleted so you can inspect logs — manage with TTL or history limits

Jobs belong to the batch API group, so their apiVersion is always batch/v1. This matters when using kubectl api-resources or writing automation that filters by group.

apiVersion: batch/v1
kind: Job
metadata:
name: db-init
spec:
completions: 1 # successful pod completions required (default: 1)
parallelism: 1 # pods allowed to run concurrently (default: 1)
backoffLimit: 6 # max retries before the Job is marked Failed (default: 6)
activeDeadlineSeconds: 300 # hard time limit for the entire Job
ttlSecondsAfterFinished: 60 # auto-delete Job + pods this many seconds after completion
template:
spec:
restartPolicy: OnFailure # required — cannot be 'Always'
containers:
- name: import
image: mongo:5
command: ["mongoimport", "..."]

restartPolicy options — and what they actually mean:

PolicyWho handles the failureWhat happens
OnFailureKubelet (node-local)Container is restarted in the same pod on the same node — fast, no rescheduling overhead
NeverJob controllerEntire pod is marked Failed; the controller creates a brand-new pod (potentially on a different node)

Running a Job more than once is necessary in two common situations:

  1. The container processes one item at a time (e.g., one database record, one file), so you need multiple runs to cover the full input dataset; or
  2. You want to distribute processing across multiple nodes to improve throughput. You can run those additional executions sequentially (one after the other) or in parallel (concurrently).
Job Parallelism Completions

The combination of completions and parallelism controls execution shape:

spec.completionsspec.parallelismBehaviour
Not setNot setSingle pod; Job completes when that pod succeeds
Set (e.g., 5)Not setSequential — one pod at a time, controller waits for success before creating the next
Not setSet (e.g., 3)3 pods start simultaneously; only one needs to succeed for the Job to complete
Set to 5Set to 2Maintains up to 2 concurrent pods until 5 succeed; as each pod finishes, the next starts immediately
Set to 5Higher than 5Excess parallelism ignored — only 5 pods are ever created

completions tracks successful completions — not total attempts. If pods fail and are retried, you may end up with more total pods than the completions value.

The core reason for running tasks through a Job rather than directly through a plain Pod is that Kubernetes guarantees task completion even when individual pods or their nodes fail. This guarantee is implemented at two distinct levels:

  • Pod level — handled by the Kubelet on the node where the pod runs
  • Job level — handled by the Job controller watching from the control plane
Job Failure Handling

The restartPolicy in the Pod template is the switch that decides which level takes action when a container fails:

  • OnFailure → the Kubelet restarts the failed container inside the same pod on the same node — no rescheduling overhead, same pod name, same PVC bindings
  • Never → the Kubelet marks the pod as Failed and the Job controller schedules an entirely new pod (potentially on a different node). If the replacement pod lands on a node that doesn’t already have the image cached, the image must be pulled again before the container can start — this adds latency to each retry cycle
MechanismFieldBehaviour
Container-level restartrestartPolicy: OnFailureKubelet restarts container in-place; RESTARTS counter increments
Pod-level retryrestartPolicy: NeverNew pod scheduled by Job controller
Retry limitspec.backoffLimitMax failed pod attempts before Job is marked Failed (default: 6). Diagnose with BackoffLimitExceeded event
Time limitspec.activeDeadlineSecondsHard wall-clock limit for the entire Job — overrides backoffLimit. Triggers DeadlineExceeded event
Exit-code rulesspec.podFailurePolicyMap specific container exit codes to actions (FailJob, Count, Ignore)
Job Failure Policy

podFailurePolicy example — fail immediately on fatal exit code:

spec:
podFailurePolicy:
rules:
- action: FailJob # skip retries entirely — mark Job as Failed immediately
onExitCodes:
containerName: main
operator: In
values: [123] # exit code 123 = unrecoverable error

When running multiple pods, you might want each pod to process a different slice of work — for example, month 1 through month 12 of sales data, or shards 0–99 of a dataset.

With the default behaviour (NonIndexed), all pods are identical clones, there’s no built-in way for a pod to know which part of the input it should handle.

Indexed mode solves this: each pod is assigned a unique zero-based integer that it can read at runtime to select its specific work item, without any external coordination.

The spec.completionMode field controls whether pods are identical or uniquely parameterized:

FeatureNonIndexed (default)Indexed
Pod identityAll pods are identicalEach pod gets a unique zero-based index
Completion conditionN pods succeed where N = completionsOne pod succeeds for each index 0 to completions - 1
Failure recoveryNew identical replacement podNew pod with the same index as the failed one
Index injectionNoneJOB_COMPLETION_INDEX env var + batch.kubernetes.io/job-completion-index annotation

Indexed mode — injecting the index:

spec:
completionMode: Indexed
completions: 12 # indices 0–11
parallelism: 3 # 3 pods run at a time
template:
spec:
containers:
- name: processor
env:
# Option A: controller injects this automatically
- name: COMPLETION_INDEX
value: "$(JOB_COMPLETION_INDEX)"
# Option B: Downward API maps the annotation to any name
- name: MONTH
valueFrom:
fieldRef:
fieldPath: metadata.annotations['batch.kubernetes.io/job-completion-index']

Pod naming in Indexed mode: <job-name>-<index>-<random> (e.g., process-2021-0-kptfr). The hostname omits the random suffix: process-2021-0.

When your workload pulls tasks from an external queue (database collection, message broker), use one of two architectures:

Coarse Parallel ProcessingFine Parallel Processing
spec.completionsMust equal the number of queue itemsNot set
spec.parallelismControls concurrencyDefines the fixed pool size and total pod count
Pod behaviourPull one item, exitLoop — pull items until queue is empty, then exit
Resource overheadOne pod scheduled per item (high)Fixed pod pool processes all items (low)
Use whenItems have variable runtime; you want per-item retry isolationItems are similar size; you want low scheduling overhead
Job Work Queue Patterns

Normally Job pods run independently and are unaware of peers. For batch tasks that require peer communication (e.g., distributed data processing), use this pattern:

  1. Set completionMode: Indexed — gives pods predictable names and hostnames
  2. Create a headless Service with clusterIP: None and a selector targeting job-name: <your-job>
  3. Set spec.template.spec.subdomain to match the headless Service name

This registers FQDNs for each pod: <job-name>-<index>.<headless-service>.<namespace>.svc.cluster.local.

Pods can then discover peers by constructing DNS names directly — no Kubernetes API queries needed, no RBAC required inside containers.

Job pods can run sidecar containers (logging agents, proxies), but they introduce a critical issue:

The fix — native sidecars in initContainers with restartPolicy: Always:

spec:
template:
spec:
restartPolicy: OnFailure
initContainers:
- name: log-collector
image: fluentd:latest
restartPolicy: Always # ← this is what makes it a native sidecar
containers:
- name: batch-worker
image: my-worker:latest
command: ["python", "process.py"]
Job Native Sidecar

Lifecycle with native sidecar:

  1. Sidecar init container starts (marked restartPolicy: Always, so it doesn’t block the main container)
  2. Main batch-worker container starts — both show 2/2 Running
  3. Main container completes → pod transitions to Completed (1/2)
  4. Kubelet automatically sends termination signal to the sidecar
  5. Sidecar exits → pod fully Completed (0/2) → Job marked Complete
Terminal window
# Check status and completion counts
kubectl get jobs
kubectl describe job <name> # start time, pod statuses, events (BackoffLimitExceeded, etc.)
# Access logs without knowing pod names
kubectl logs job/<name> --all-containers --prefix
# Find pods by auto-generated label
kubectl get pods -l job-name=<name>
# Create a Job in a suspended state (no pod is created until you resume it)
# Set spec.suspend: true in the manifest, then apply and resume when ready
# Suspend a running Job (controller immediately terminates active pods + emits a Suspended Event)
kubectl patch job <name> -p '{"spec":{"suspend": true}}'
# Resume a suspended Job (controller re-creates pods and continues from where it left off)
kubectl patch job <name> -p '{"spec":{"suspend": false}}'
# Delete a Job at any time — even while its pods are still running
# The controller terminates the running pods as part of cascade deletion
kubectl delete job <name>
# Delete Job but keep pods (for post-mortem inspection)
kubectl delete job <name> --cascade=orphan

Auto-cleanup with TTL:

spec:
ttlSecondsAfterFinished: 300 # delete Job + pods 5 minutes after completion

A CronJob is a wrapper around the Job resource that creates new Job objects on a cron schedule. The controller manages the lifecycle of those child Jobs, cleaning up history according to configured limits.

CronJob Overview
CronJob (schedule + jobTemplate)
↓ creates on schedule
Job (completions, parallelism, pod template)
↓ creates
Pods (run to completion)
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-report
spec:
schedule: "0 2 * * *" # 2am daily
timeZone: "Asia/Kolkata" # optional — defaults to controller-manager's local TZ
concurrencyPolicy: Forbid # how to handle overlapping runs
successfulJobsHistoryLimit: 3 # keep last 3 successful Jobs (default: 3)
failedJobsHistoryLimit: 1 # keep last 1 failed Job (default: 1)
startingDeadlineSeconds: 60 # give up if Job can't start within 60s of schedule time
jobTemplate:
metadata:
labels:
app: nightly-report # must be explicit — CronJob does NOT auto-inject labels
spec:
template:
metadata:
labels:
app: nightly-report
spec:
restartPolicy: OnFailure
containers:
- name: updater
image: mongo:5
command: ["mongosh", "..."]

Time zone behaviour

By default, the CronJob controller evaluates schedules using the time zone of the Controller Manager process — not your local time zone. This means if your control plane runs in a different region (e.g., UTC on a managed cloud cluster while your team expects IST or EST), your CronJob will fire at unintended local times. The timeZone field in spec makes the intent explicit:

spec:
schedule: "0 2 * * *"
timeZone: "Asia/Kolkata" # IANA time zone database names (e.g. "UTC", "America/New_York")

If timeZone is omitted, the schedule is interpreted in the Controller Manager’s local time zone — which is typically UTC on cloud-managed clusters.

Standard five-field crontab format (same syntax as Linux cron — see Task Scheduling for the full reference):

┌──────────── minute (0–59)
│ ┌─────────── hour (0–23)
│ │ ┌────────── day of month (1–31)
│ │ │ ┌─────── month (1–12 or JAN–DEC)
│ │ │ │ ┌──── day of week (0–6 or SUN–SAT)
│ │ │ │ │
* * * * *
PatternMeaning
*Match any value
1-5Range (inclusive)
1,3,5Specific values
*/15Every 15th value (steps)
0 2 * * 1-52am on weekdays

Schedule aliases:

AliasEquivalent
@hourly0 * * * *
@daily / @midnight0 0 * * *
@weekly0 0 * * 0
@monthly0 0 1 * *
@yearly / @annually0 0 1 1 *

If a scheduled Job takes longer than the scheduling interval, concurrent runs can accumulate:

PolicyBehaviour
Allow (default)Multiple Jobs can run concurrently
ForbidSkip the new run if the previous Job is still active; log JobAlreadyActive event
ReplaceDelete the active Job and immediately start a new one

If the control plane is overloaded or the controller manager goes offline, scheduled Jobs may be delayed or skipped entirely.

spec.startingDeadlineSeconds: If a Job can’t start within this many seconds of its scheduled time, it’s abandoned with a MissSchedule event.

Without a start deadline, after a controller outage the CronJob controller tries to catch up by running all missed executions (up to 100). If it detects more than 100 missed runs, it gives up and logs TooManyMissedTimes — setting a startingDeadlineSeconds prevents this catch-up storm by skipping stale runs.

The controller auto-deletes old Jobs based on:

FieldDefaultEffect
successfulJobsHistoryLimit3Keep the last N completed Jobs (and their pods for log inspection)
failedJobsHistoryLimit1Keep the last N failed Jobs

Setting both to 0 causes immediate cleanup of all finished Jobs. The associated pods are deleted when their parent Job is deleted.

Terminal window
# List CronJobs (-o wide shows container images)
kubectl get cj
kubectl get cj -o wide
# Stream logs from the most recent runs (if you added custom labels)
kubectl logs -l app=nightly-report --all-containers --prefix
# Manually trigger a one-off run without waiting for the schedule
kubectl create job manual-run --from cronjob/nightly-report
# Check last scheduled and last successful timestamps
kubectl describe cj nightly-report
# Look for: status.lastScheduleTime, status.lastSuccessfulTime
# List Jobs spawned by a CronJob (filter by your custom label)
kubectl get jobs -l app=nightly-report
# NAME COMPLETIONS DURATION AGE
# nightly-report-27755219 1/1 36s 37s
# Suspend — stops the controller from scheduling new Jobs;
# any Jobs already running are allowed to finish normally
kubectl patch cj nightly-report -p '{"spec":{"suspend": true}}'
# After suspending, kubectl get cj shows SUSPEND=True while a Job may still be ACTIVE
# NAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE
# nightly-report * * * * * <none> True 1 19s
# → SUSPEND True + ACTIVE 1: the current Job finishes; no new Job starts until resumed
# Resume — controller begins scheduling Jobs again from the next scheduled slot
kubectl patch cj nightly-report -p '{"spec":{"suspend": false}}'
# You can also CREATE a CronJob in a suspended state from the start
# (set spec.suspend: true in the manifest and apply — no Jobs are scheduled until you resume)
# Delete CronJob and all its Jobs/pods
kubectl delete cj nightly-report
# Delete CronJob but preserve existing Jobs and pods
kubectl delete cj nightly-report --cascade=orphan

The Job name in the output follows a predictable pattern: <cronjob-name>-<unix-epoch-minutes>. The number suffix is the scheduled trigger time expressed as Unix Epoch Time converted to minutes — not a random ID. This makes it straightforward to correlate a Job to the exact schedule slot it was created for.

Testing a CronJob without waiting for its schedule

The --from flag on kubectl create job lets you trigger a one-off run from any CronJob at any time. This is the standard way to test a CronJob immediately rather than waiting for the next scheduled slot:

Terminal window
kubectl create job my-job --from cronjob/my-cronjob

Checking whether the last run succeeded

kubectl get cj only shows the number of currently active Jobs and when the last Job was scheduled — it does not show whether the last Job completed successfully. To get success/failure status you have two options:

Terminal window
# Option 1 — list spawned Jobs directly (shows COMPLETIONS column)
kubectl get jobs -l app=<your-label>
# Option 2 — read CronJob status in YAML (most detailed)
kubectl get cj <name> -o yaml
# Look for:
# status.lastScheduleTime: when the last Job was triggered
# status.lastSuccessfulTime: when the last Job actually completed successfully
# status.active: list of currently running Job references

SymptomLikely causeFix
Job stuck at 0 completions, pod shows 1/2 RunningSidecar in spec.containers prevents pod from completingMove sidecar to initContainers with restartPolicy: Always
Job status Failed, event BackoffLimitExceededContainer keeps failing; hit retry limitCheck pod logs; fix the application error; delete and recreate the Job
Job stuck running, event DeadlineExceededactiveDeadlineSeconds expiredJob is in an infinite hang or deadlock; increase deadline or fix the root cause
CronJob skipping scheduled runsConcurrency policy Forbid + previous Job still activeEither switch to Replace, scale up resources, or investigate why the Job is slow
CronJob logs TooManyMissedTimes after control plane outageNo startingDeadlineSeconds; >100 missed runs detectedSet startingDeadlineSeconds to prevent catch-up storms
Pods accumulating after Job finishesNo TTL and high successfulJobsHistoryLimitSet ttlSecondsAfterFinished or reduce history limits
CronJob pods invisible to kubectl logs -l ...No custom labels in jobTemplateAdd labels explicitly in both jobTemplate.metadata.labels and spec.template.metadata.labels