A Kubernetes Job starts one or more Pods, lets them work through a task, and counts as done once the desired number of Pods has finished successfully. A Kubernetes CronJob is the timer switch in front of it: it creates new Jobs from a template again and again on a cron schedule. A Job fits one-off tasks like a database migration, a CronJob fits recurring ones like a nightly backup.
Both objects are deliberately simple, yet many teams stumble in the same spots: failed Jobs that keep spawning new Pods, CronJobs that overlap, or hundreds of finished Pods nobody cleans up.
I run Kubernetes clusters in production and wrote the Kubernetes Practical Guide for Rheinwerk Computing (2024). Jobs and CronJobs run in my clusters for backups, database maintenance and imports, and most of the mistakes here are ones I have run into myself or at clients. You can find all my Kubernetes articles on the Kubernetes page.
Job or Deployment: when a task has an end
Almost everything you have created in Kubernetes so far is meant to run forever. A Deployment keeps a fixed number of Pods alive and replaces any that fail, right for a web server, wrong for a script that migrates a table and exits: the Deployment would treat the exited container as a crash and keep restarting it.
I explain the difference with two kinds of workers. A Job is the contractor with a clear project brief: it shows up, does exactly this one thing, and leaves. The Deployment is the permanent employee with ongoing work and no defined end, and if it drops out, someone has to fill the position right away.
|
Job |
Deployment |
| Kind of task |
one-off, with a defined end |
ongoing, no end |
| What happens when the container exits |
Job counts the success, Pod stays as Completed |
Pod restarts immediately |
| Reaction to failure |
new attempt until backoffLimit is reached |
restart with CrashLoopBackOff |
| Scaling |
via completions and parallelism |
via replicas |
| Typical examples |
migration, import, data conversion, draining a queue |
web server, API, worker under constant load |
The rule of thumb: if your program exits on its own with exit code 0 once it's done, it belongs in a Job. If it runs in an endless loop waiting for requests, it belongs in a Deployment.
Kubernetes Job: the manifest and the fields that matter
A Job manifest contains a Pod template you already know from the Deployment, plus a few fields that only make sense for Jobs. The following example starts five Pods, two at a time, each one sleeping three seconds and then exiting successfully:
apiVersion: batch/v1
kind: Job
metadata:
name: demo-job
spec:
completions: 5
parallelism: 2
backoffLimit: 4
activeDeadlineSeconds: 120
ttlSecondsAfterFinished: 600
template:
spec:
restartPolicy: Never
containers:
- name: worker
image: busybox:stable
command: ["/bin/sleep"]
args: ["3"]
completions sets how many Pods must finish successfully for the Job to count as successful, parallelism sets how many may run at once. Both default to 1: one Pod, one task, done. backoffLimit caps the retries on failure, more on that shortly. activeDeadlineSeconds is the kill switch: if the Job runs longer than this deadline, Kubernetes stops all Pods and marks it failed. ttlSecondsAfterFinished cleans up the finished Job automatically.
One field deserves special attention: restartPolicy in the Pod template. For a Deployment, Always is the default, for a Job that exact value is forbidden. You must set Never or OnFailure, or the API server rejects the manifest, the most common beginner mistake.
After the kubectl apply, you watch the progress like this:
$ kubectl get jobs
NAME STATUS COMPLETIONS DURATION AGE
demo-job Running 2/5 9s 9s
$ kubectl get pods -l job-name=demo-job
NAME READY STATUS RESTARTS AGE
demo-job-4xk9p 0/1 Completed 0 9s
demo-job-b7m2q 0/1 Completed 0 9s
demo-job-hs8ww 1/1 Running 0 3s
demo-job-tq5lz 1/1 Running 0 3s
The Job automatically sets the job-name label on every Pod it creates, so you can find them all, and kubectl logs -l job-name=demo-job gets you the logs of all of them at once. They stay as Completed afterward, so you can still read the logs. How you check the manifest is covered in Kubernetes YAML: Understanding Manifests.
Three Job types: one-off, parallel, queue worker
With completions and parallelism you can build three patterns that cover almost every practical case:
| Type |
completions |
parallelism |
When the Job is done |
Example |
| One-off Job |
1 (default) |
1 (default) |
one Pod finished successfully |
database migration, initial import |
| Fixed-count parallel Job |
n |
m |
n Pods finished successfully |
converting 1,000 images in batches |
| Queue worker |
unset |
m |
one Pod finished successfully, the rest finish their work |
draining a message queue |
The one-off Job is the normal case: you set nothing, Kubernetes starts one Pod. If it fails, a new one comes, until one goes through cleanly or the backoffLimit is reached.
The fixed-count parallel Job fits work that splits into equal-sized chunks. With completions: 10 and parallelism: 3, three Pods always run at once until ten have succeeded, each with the same spec. If the Pods need different parts of the task, Kubernetes offers completionMode: Indexed: each Pod gets its number as the environment variable JOB_COMPLETION_INDEX and uses it to pick, say, its slice of a list.
The queue worker is my favorite pattern. You leave out completions and set only parallelism. Several Pods pull messages off a queue, RabbitMQ for example, and work through them. As soon as a Pod finds the queue empty, it exits with code 0. Kubernetes starts no further Pods after that, lets the running ones finish, and the Job counts as successful. The one requirement: your program must actually stop when the queue is empty, or the Job never knows when it's done. In the book I build this pattern fully with RabbitMQ, a publisher filling the queue and a consumer draining it.
backoffLimit: what happens when a Job fails
The difference between a Job that fails cleanly and one that floods your cluster with Pods comes down to three fields: restartPolicy, backoffLimit, activeDeadlineSeconds.
When a container exits with a non-zero code, the Pod's restartPolicy decides first. With OnFailure, Kubernetes restarts the container in the same Pod and counts the restarts up. With Never, the failed Pod stays as Error, and the Job creates a brand new one. I prefer Never, since every attempt then has its own Pod with its own logs.
backoffLimit sets how many failed attempts the Job tolerates before it counts as failed. The default is 6. Between attempts, Kubernetes waits exponentially longer, 10 seconds, then 20, then 40, capped at six minutes, the same behavior as CrashLoopBackOff on a Deployment, just with an end. After that, the Job shows status Failed with reason BackoffLimitExceeded, and nothing happens until you step in.
activeDeadlineSeconds adds an absolute ceiling. A script that hangs and never exits slips past the backoffLimit, since it never technically fails, but not past the deadline: once it passes, Kubernetes stops all running Pods and sets the Job to Failed with reason DeadlineExceeded, ahead of the backoffLimit. I set it on every production Job, since a backup still running after two hours isn't a backup anymore, it's a problem.
Here's how you see why a Job failed:
$ kubectl describe job demo-job
...
Conditions:
Type Status Reason Message
---- ------ ------ -------
Failed True BackoffLimitExceeded Job has reached the specified backoff limit
$ kubectl get pods -l job-name=demo-job
NAME READY STATUS RESTARTS AGE
demo-job-2ndj7 0/1 Error 0 4m
demo-job-k8hxc 0/1 Error 0 3m
demo-job-vv5rn 0/1 Error 0 90s
$ kubectl logs demo-job-vv5rn
The events under describe and the last Pod's logs are almost always enough for diagnosis. For more targeted rules, such as aborting a Job on exit code 42 instead of retrying, there's the Pod Failure Policy, described in the Kubernetes Job documentation.
Kubernetes CronJob: schedule, time zone and the manifest
A CronJob creates a Job from a template at fixed points in time, nothing more: it has no Pod template directly, but a jobTemplate, holding everything you've already seen above. The following example is a typical case from my clusters, a nightly database dump into S3-compatible object storage:
apiVersion: batch/v1
kind: CronJob
metadata:
name: postgres-dump
spec:
schedule: "30 2 * * *"
timeZone: "Europe/Berlin"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 600
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 3600
template:
spec:
restartPolicy: Never
containers:
- name: dump
image: postgres:alpine
command: ["/bin/sh", "-c"]
args:
- pg_dump "$DATABASE_URL" | gzip > /backup/db.sql.gz && echo done
envFrom:
- secretRef:
name: postgres-dump-credentials
volumeMounts:
- name: backup
mountPath: /backup
volumes:
- name: backup
emptyDir: {}
The core is schedule, a classic cron expression with five fields: minute, hour, day, month, weekday. A few examples I use regularly:
| schedule |
Meaning |
*/5 * * * * |
every five minutes |
0 * * * * |
every full hour |
30 2 * * * |
daily at 2:30 am |
0 3 * * 0 |
Sundays at 3:00 am |
0 6 1 * * |
on the first of every month at 6:00 am |
@daily |
once a day at midnight |
Without the timeZone field, Kubernetes interprets the schedule in the controller's time zone, usually UTC. "2:30 am" then becomes 3:30 or 4:30 am German time, depending on daylight saving. With timeZone: "Europe/Berlin", the Job runs at the time you actually mean. I set this field in every CronJob now. The typical damage from this mistake: a backup run lands unnoticed right in the middle of a morning load spike.
About the example itself: the dump lands in an emptyDir here to keep things simple, in production the script would push it via aws s3 cp or rclone into object storage, with credentials from a secret, never from the manifest. What else belongs in a complete backup, especially etcd and volumes, is covered in Kubernetes Backup: etcd and Volumes.
concurrencyPolicy, startingDeadlineSeconds and history: the fields for running it in production
The four fields below schedule in the example are what turns a CronJob from a toy into something you can run in production.
concurrencyPolicy governs what happens when the next point in time arrives and the previous Job is still running. Allow is the default and lets both run in parallel, fatal for a backup: two dumps writing into the same file at once. Forbid skips the new run, Replace stops the old one and starts the new. For almost everything I run, Forbid is the right choice.
startingDeadlineSeconds answers what happens when a point in time was missed, say because the controller was briefly down or Forbid blocked a run. Within the deadline, Kubernetes catches up on the missed start, after that it lets it slide. Without this field there is no deadline, and a special case kicks in: past 100 missed runs, the CronJob stops starting altogether and only logs an error, typically after a long suspend or a multi-day outage on a per-minute schedule. A set deadline prevents that.
successfulJobsHistoryLimit and failedJobsHistoryLimit set how many finished Jobs, Pods included, the CronJob keeps. Defaults are 3 successful and 1 failed. I turn the second value up, because a single failed Job, logs included, disappears after the next failure, and I'd need those logs on Monday morning.
Then there's suspend: true, which pauses a CronJob without deleting it: Kubernetes creates no new Jobs until you set it back to false, the clean way to handle a maintenance window. The full field list is in the Kubernetes CronJob documentation.
Starting, checking and cleaning up a CronJob by hand
The schedule is one half of the CronJob, the other: you can start a Job from the same template by hand at any time:
$ kubectl create job postgres-dump-manual --from=cronjob/postgres-dump
job.batch/postgres-dump-manual created
$ kubectl get jobs
NAME STATUS COMPLETIONS DURATION AGE
postgres-dump-29284510 Complete 1/1 48s 7h
postgres-dump-manual Running 0/1 5s 5s
I use this more often than expected: before a migration I pull a backup out of turn, after a manifest change I test it instead of waiting until 2:30 am. For processes meant to be triggered manually, a CronJob with suspend: true is an honest solution: the manifest lives versioned in the repository, the template is reviewed, and starting it is one command instead of a hand-rolled YAML snippet.
Cleanup has two mechanisms that complement each other. The CronJob's history limits only apply to Jobs it created itself. The manually created Job from above doesn't fall under that, it stays until you delete it or its ttlSecondsAfterFinished expires. That's why I set the TTL in the jobTemplate, so it applies both ways. Finished Jobs nobody cleans up clutter the listing, and a second kubectl apply of the same manifest fails since the name is taken. CronJobs append a timestamp to their Jobs, so no naming conflicts happen.
In my clusters, CronJobs live as manifests in Git and get rolled out through GitOps. One thing tripped me up early on: a manually started Job doesn't live in the repository, so the GitOps tool flags it as drift or removes it. The fix was keeping the TTL short and excluding such Jobs from reconciliation.
Frequently asked questions
What is the difference between a Job and a CronJob in Kubernetes?
A Job runs a task once and is done once the required number of Pods finishes successfully. A CronJob creates new Jobs from a template on a schedule, so it isn't its own execution mechanism, it's a generator of Jobs you check with the same commands.
How do I start a Kubernetes CronJob manually?
With kubectl create job <name> --from=cronjob/<cronjob-name> you create a Job from the CronJob's template right away, independent of the schedule. The name has to be unique in the namespace. It doesn't count toward the history, so delete it yourself or set a TTL in the jobTemplate.
What does backoffLimit mean for a Kubernetes Job?
backoffLimit is the number of failed attempts after which Kubernetes marks the Job failed and stops starting new Pods. The default is 6, with growing wait times, starting at 10 seconds, up to six minutes at most. A Job that hangs forever isn't caught by this; for that you need activeDeadlineSeconds.
Why doesn't my CronJob run at the expected time?
The most common cause is the time zone: without the timeZone field, the controller's time zone applies, usually UTC. The second is concurrencyPolicy Forbid with a still-running predecessor, which skips the run. Check LAST SCHEDULE and SUSPEND with kubectl get cronjob, and the events with kubectl describe cronjob. A note about too many missed runs means set startingDeadlineSeconds.
How many old Jobs does a CronJob keep?
By default the last 3 successful and the last 1 failed, controlled by successfulJobsHistoryLimit and failedJobsHistoryLimit. Older Jobs get deleted along with their Pods and logs. To trace an error, raise the limit for failed Jobs or ship the logs into central logging.
Where to go next
Jobs and CronJobs are the objects you use in Kubernetes for anything with an end. The Job manifest builds on the Pod template from Kubernetes Deployment: Rollouts Explained, and how you write clean manifests is covered in Kubernetes YAML: Understanding Manifests. The most important CronJob use case in my clusters, backups, has its own article: Kubernetes Backup: etcd and Volumes.
My suggestion for today: take the Job from above, set the image to a command that fails with exit 1, and watch with kubectl get pods -w as Pods appear with growing gaps until the backoffLimit is reached. Then put the same template into a CronJob with */1 * * * * and concurrencyPolicy: Forbid and watch what a run over a minute triggers.
In full detail, including the complete queue worker setup with RabbitMQ, this is covered in chapter 5 of my Kubernetes Practical Guide (Rheinwerk Computing).