A CronJob is a Job template plus a cron expression. Every time the expression matches, the controller creates a new Job, and that Job runs a pod to completion. Nothing stays resident between runs: a batch job inherits the cluster’s retry and scheduling machinery without a daemon kept alive to trigger it.

The tension is that a schedule is only approximately honoured: Kubernetes promises a Job “approximately once per execution time”, covering both failure directions — two Jobs for one tick, or none at all. Anything you schedule has to be safe to run twice, because the guarantee is at-least-once, not exactly-once.

A schedule makes a job

schedule takes the familiar five fields — minute, hour, day of month, month, day of week — with Vixie-cron ranges and steps, so */10 * * * * fires every ten minutes and */15 9-17 * * mon-fri stays inside business hours. ? means *, and @daily and @hourly expand to their equivalents. A CronJob with no timeZone follows the controller manager’s clock, so moving the control plane changes when it runs. A missing field is a validation error, one of the few CronJob mistakes that fails loudly.

Each match produces a separate Job named after the CronJob, which is why the CronJob name is capped at 52 characters: the controller appends eleven characters and the Job name must still fit in 63. A CronJob never runs the work itself; it only creates Jobs, which own the pods. Since Kubernetes v1.32 the Job also carries a batch.kubernetes.io/cronjob-scheduled-timestamp annotation holding the time it was meant to run, the only way to tell a late run from an on-time one after a controller restart.

Concurrency is a policy, not a guess

concurrencyPolicy decides what happens when a tick arrives while the previous Job is still running. Allow, the default, starts a second copy. Forbid skips the tick. Replace cancels the running Job and starts a fresh one. The policy applies only to Jobs from the same CronJob, so two CronJobs never block each other.

Replace is the dangerous one: if the Job reliably outruns its interval, every tick kills the run that was about to finish and the work never completes. Forbid is usually what people mean by “don’t overlap”, with two caveats — skipped runs are lost, and once the running Job finishes, startingDeadlineSeconds is still evaluated, so a long-overdue run may start anyway.

Missed runs have a budget of a hundred

startingDeadlineSeconds bounds how late a missed run is still worth starting; past that deadline the run is skipped and counted as failed, and skipping one occurrence does not stop the schedule, because the next matching time still fires. Left unset, missed runs have no deadline of their own, but they are still subject to the controller’s hard cap. It counts the schedules missed since the last scheduled time, and above one hundred it starts nothing and logs too many missed start times. Set or decrease .spec.startingDeadlineSeconds or check clock skew.

The documented example is concrete. A per-minute CronJob whose controller is down from 08:29 to 10:21 has missed 112 schedules, so nothing catches up on recovery. Add startingDeadlineSeconds: 200 and the controller instead counts only the schedules missed inside the last 200 seconds — three of them — and the Job does start. The same field hides a trap: values below ten seconds are unreliable, because the controller only re-evaluates every ten seconds. suspend: true parks a CronJob without touching Jobs already running, but suspended executions count as missed, so unsuspending without a deadline fires the whole backlog at once.

When it breaks

The classic failure is a Job that overlaps itself. A twenty-five minute report on a ten-minute schedule, with the default Allow, peaks at three concurrent copies reading the same input and writing the same output. It never presents as an error: duplicated rows, doubled API spend, or a lock timeout in one copy out of three. Either move to Forbid and accept skipped runs, or make the Job idempotent with a lease so the late copy exits early.

The second failure is silent catch-up loss. A control-plane outage longer than a hundred schedule intervals means the whole backlog is discarded on recovery, and the only trace is a line in the controller manager’s log. That is right for a per-minute poll and wrong for a nightly backup, which is what startingDeadlineSeconds is for: setting it to the real tolerance — eight hours for a twice-daily backup — both bounds how stale a run may be and keeps the hundred-interval cap permanently out of reach.

Check it quickly

Read the schedule, the policy and the last firing time together:

kubectl get cronjob nightly-report -o jsonpath='{.spec.schedule} {.spec.concurrencyPolicy} {.spec.startingDeadlineSeconds}{"\n"}'
kubectl get cronjob nightly-report -o jsonpath='{.status.lastScheduleTime}{"\n"}'
kubectl get jobs --sort-by=.metadata.creationTimestamp -o custom-columns=NAME:.metadata.name,START:.status.startTime,SUCCEEDED:.status.succeeded
kubectl get events --field-selector involvedObject.kind=CronJob --sort-by=.lastTimestamp | tail -5

lastScheduleTime should sit within one schedule interval of now; a gap of hours means the controller was down or the expression never matched. Overlapping START values with SUCCEEDED empty mean Allow plus a Job that outruns its interval, and a too many missed start times event names the missed-run rule directly.

Sources

  • Kubernetes CronJob, schedule syntax, concurrency policy, the 100-missed-schedule rule and history limits, checked 2026-09-16.
  • CronJob v1 API reference, field-level definition of the CronJob spec, checked 2026-09-16.