Friday the 13th does not exist in cron

27 July 2026

There is a tool for this on the site

Write this schedule and ask a room of engineers what it does:

0 0 13 * 5

Almost everyone reads it as midnight on Friday the 13th. Minute zero, hour zero, day 13, any month, Friday. Every field composes the way you expect, so the sentence assembles itself.

It is wrong. That expression means midnight on the 13th of every month, and also midnight every Friday. Roughly 64 runs a year instead of one or two.

The rule

When both the day-of-month and the day-of-week fields are restricted, cron fires when either one matches. Not both. The two day fields are ORed together, while every other field in the expression is ANDed.

When only one of them is restricted and the other is *, the * is ignored entirely and only the restricted one applies. That is why 0 0 13 * * and 0 0 * * 5 both behave exactly as you would expect, and why combining them does not.

The behaviour comes from the original Vixie cron implementation and is preserved by every scheduler that claims cron compatibility, including Kubernetes CronJob. It is not a bug and it will not be changed.

Why it survives review

Because the broken version looks like the working version.

There is no syntax error, nothing to validate against, and the expression is short enough that it reads as obviously correct. Both fields are individually sensible. The composition is the only thing wrong, and composition is exactly what a reviewer skims.

It also fails in the safe direction on first inspection: the job does run on Friday the 13th. It just also runs on 63 other days. A test that checks “did it fire on the target date” passes.

How it shows up

A report duplicated for a quarter before anybody notices. The schedule was meant to be monthly on the 13th and is also firing weekly. Downstream the report is idempotent enough that four copies look like one.

A “monthly” invoice run that fires weekly. This one does get noticed, and usually by a customer.

A Kubernetes CronJob with unexplained concurrent runs. Especially with concurrencyPolicy: Allow, which is the default. The extra firings overlap the intended one, and the symptom is a job that intermittently interferes with itself.

A cleanup task running far more often than intended. Usually harmless, and occasionally not if the cleanup is destructive or expensive.

The common thread is that the extra runs are the same job doing the same work, so it is a volume problem rather than a correctness problem, and volume problems hide in aggregate.

The wrong instinct: make the expression more specific

The reflex is to add a guard to the schedule. There is no way to express AND across the two day fields in standard cron, so this cannot work, and the attempts produce expressions that look cleverer and behave identically.

Two things that do work.

Move the condition into the job. Restrict the schedule to the field you can express and check the other in code:

0 0 13 * *
# exit unless today is a Friday
[ "$(date +%u)" = "5" ] || exit 0

Unambiguous, testable, and the intent is written where a reader will find it. For anything genuinely conditional this is the right answer.

Use a scheduler that supports it. Quartz-style cron, used by several JVM schedulers and some cloud products, has ? for “no specific value”, L for last-day and # for nth-weekday. In Quartz, 6#3 means the third Friday.

Standard cronQuartz-style
Fields56 or 7, seconds first
Both day fields restrictedORRejected; use ? in one
Nth weekdaynot expressible6#3
Last day of monthnot expressibleL

Quartz cron is not standard cron and the field count differs, so an expression copied between the two is its own class of bug. Check which dialect your scheduler speaks before reusing an expression from documentation.

What this means for Kubernetes CronJob

CronJob uses standard cron semantics, so the OR applies. Three related things worth knowing at once, because they compound:

concurrencyPolicy defaults to Allow. The unexpected extra firings overlap rather than queueing or being skipped. If a job is not safe to run twice at once, Forbid is what you want, and it is not the default.

startingDeadlineSeconds interacts badly with missed windows. If the controller cannot start a job within the deadline it counts as a miss, and after 100 consecutive misses the CronJob stops scheduling entirely, logging an error nobody is watching for.

Timezone handling is explicit now. spec.timeZone is stable, so a schedule no longer silently depends on the controller’s timezone. CronJobs written before that are interpreted in the controller’s zone, and moving a cluster between regions changes when they fire.

Auditing what you already have

The check is mechanical and worth running once across everything.

Find every expression where field 3 and field 5 are both not *. That is the entire test. Across a repository of Kubernetes manifests:

grep -rhoE 'schedule: *"[^"]+"' . \
  | sed 's/.*"\(.*\)"/\1/' \
  | awk 'NF==5 && $3!="*" && $5!="*" { print }'

Every line it prints is either a deliberate OR, which is rare, or a bug.

For each one: decide which field expresses the real intent, restrict the schedule to that, move the other into the job as a guard, and write the intent in a comment. The next reader will have the same instinct you did.

When the OR is what you wanted

It happens, and recognising it stops you “fixing” a working schedule.

Backups on the 1st and 15th, and every Sunday. 0 3 1,15 * 0 does exactly that, and the OR is doing the intended work.

A maintenance window meant to catch both a calendar date and a weekday. Same shape.

If the OR is intentional, say so in a comment next to the schedule. An expression that looks like the classic bug and is not will otherwise be corrected by somebody eventually, and that change will look like a cleanup rather than a behaviour change.

The short version

  • Both day fields restricted means OR, not AND. 0 0 13 * 5 fires about 64 times a year.
  • One restricted and one * behaves exactly as expected, which is why the bug stays invisible until they are combined.
  • Standard cron cannot express AND across the day fields. Guard in the job, or use a Quartz-style scheduler.
  • Kubernetes CronJob uses standard semantics, and concurrencyPolicy: Allow is the default, so extra firings overlap.
  • Audit: any five-field expression where fields 3 and 5 are both not *.
  • If the OR is deliberate, write that down.

The cron expression parser reads an expression back in plain language, lists the next runs, and flags the two-day-field case explicitly, which is the one that reads correctly and is not.