Every Other Week
0 0 */14 * * 0 0 0 1/14 * ? 0 0 */14 * * 0 0 1/14 * ? * 0 0 0 */14 * * 0 0 */14 * * `0 0 */14 * *` does NOT run every other week — the day-of-month field resets monthly, so it fires on the 1st, 15th and 29th: 35 runs a year, not 26.
Use in your stack
# WRONG — fires the 1st, 15th and 29th of every month (35 runs/year).
0 0 */14 * * /usr/local/bin/fortnightly.sh
# CORRECT — run weekly, then gate on the parity of the week.
# Weeks elapsed since the Unix epoch; alternates reliably across new year.
0 9 * * 1 [ $(( ($(date -u +\%s) / 604800) \% 2 )) -eq 0 ] && /usr/local/bin/fortnightly.sh
{
"note": "CronTrigger cannot express a fortnightly cadence.",
"trigger": "SimpleTrigger",
"repeatIntervalHours": 336,
"repeatCount": -1
}
# Kubernetes CronJob has no biweekly schedule either.
# Fire weekly and exit early on the off week.
apiVersion: batch/v1
kind: CronJob
metadata:
name: fortnightly
spec:
schedule: "0 9 * * 1"
timeZone: "Etc/UTC"
jobTemplate:
spec:
template:
spec:
containers:
- name: fortnightly
image: my-image:latest
command:
- /bin/sh
- -c
- '[ $(( ($(date -u +%s) / 604800) % 2 )) -eq 0 ] || exit 0; exec /app/run.sh'
restartPolicy: OnFailure
# No biweekly rate or cron form. Either gate inside the target,
# or use a 14-day rate expression and accept drift from the clock:
rate(14 days)
// Cron cannot do it — use a fixed rate instead.
// 14 days in milliseconds. Counts from application start, not the calendar.
@Scheduled(fixedRate = 1_209_600_000L)
public void everyOtherWeek() { /* ... */ }
on:
schedule:
- cron: '0 9 * * 1' # weekly; the job decides whether this week counts
jobs:
fortnightly:
runs-on: ubuntu-latest
steps:
- id: parity
run: echo "run=$(( ($(date -u +%s) / 604800) % 2 ))" >> "$GITHUB_OUTPUT"
- if: steps.parity.outputs.run == '0'
run: ./scripts/fortnightly.sh
Next runs
Pick a timezone to see when this expression fires next.
- 012026-08-29T00:00:00.000Z
- 022026-09-01T00:00:00.000Z
- 032026-09-15T00:00:00.000Z
- 042026-09-29T00:00:00.000Z
- 052026-10-01T00:00:00.000Z
- 062026-10-15T00:00:00.000Z
- 072026-10-29T00:00:00.000Z
- 082026-11-01T00:00:00.000Z
- 092026-11-15T00:00:00.000Z
- 102026-11-29T00:00:00.000Z
Variations
Common use cases
- Fortnightly billing runs, payroll exports, or invoice generation.
- Biweekly sprint automation — retro reminders, board archiving, velocity reports.
- Alternating-week on-call rotations and handover notifications.
- Every-other-week digest emails where weekly is too noisy.
Gotchas
- **Cron cannot express this at all.** Every field is matched against the current wall clock. There is no counter, no memory of the previous firing, and nothing that can encode "skip every second occurrence." Any biweekly schedule has to be built outside the expression.
- **`0 0 */14 * *` is the trap.** The day-of-month field runs 1–31, so the `/14` step yields 1, 15 and 29 — then the counter resets at the start of the next month. The gaps run 14, 14, then 2 or 3 days.
- **It fires 35 times a year, not 26.** Eleven months produce three runs each and February produces two. A true fortnightly schedule fires 26 times.
- **In a leap year it fires on two consecutive days.** February 29 and March 1, one day apart — and that leap year total is 36 runs.
- **The popular `date +%V` parity gate is broken.** ISO years occasionally have 53 weeks, which puts two odd-numbered weeks back to back and drops a firing. 2026 is one of those years, so a job gated this way skips a beat over new year.
- The day-of-month field is also subject to the [DOM/DOW OR trap](/guides/day-of-month-day-of-week-trap) — adding a day-of-week restriction here makes the schedule fire on days matching *either* field, not both.
- **Escape the `%` when the parity gate lives in the crontab line.** In a crontab command, an unescaped `%` is turned into a newline and everything after it is fed to the job on stdin, so `date +%s` silently becomes `date +` and the test collapses. Write `\%` — or, better, move the gate into the script and keep the crontab line simple.
There is no cron expression for “every other week.” That is not a gap in a particular implementation — it follows from what a cron expression is.
Every field in a cron expression is a set of values matched against the current wall clock. The scheduler wakes up, asks “does now match?”, and fires if it does. There is no counter, no record of the previous firing, and therefore no way to express “the second Monday since the last time this ran.” Fortnightly is a statement about intervals between runs; cron only speaks about positions on the calendar.
The trap: 0 0 */14 * *
This is the expression almost everyone tries, and it is wrong in a specific, reproducible way.
The day-of-month field runs 1 to 31. The step syntax */14 means “start at the field minimum and keep adding 14 while still in range”: day 1, day 15, day 29, then 43 — out of range, so it stops. At the start of the next month the field resets to 1 and the same three values repeat.
So the actual schedule is the 1st, 15th and 29th of every month:
Jan 1 ──14d──▶ Jan 15 ──14d──▶ Jan 29 ──3d──▶ Feb 1 ──14d──▶ Feb 15 ...
Two clean fortnights, then a two- or three-day stub where the month boundary cuts the cycle short. Over a year that comes to 35 runs — eleven months contributing three each, plus two in February — against the 26 a real fortnightly schedule produces. The expression over-fires by about a third, and the runs land on wildly uneven gaps.
Leap years are worse
In a leap year February has a 29th, so the third firing happens — and then the counter resets for March:
2028-02-15 ──14d──▶ 2028-02-29 ──1d──▶ 2028-03-01
Two runs one day apart. Any job that assumes at least a fortnight of separation — a billing run, a digest email, an on-call handover — will double-process that boundary. The leap-year total is 36.
This is the same failure as every 45 minutes and every 45 seconds: a step that doesn’t divide its field evenly, so the schedule only looks periodic until the field wraps. The difference is that the day-of-month field wraps at a variable point — 28, 29, 30 or 31 — so the error isn’t even consistent month to month.
The other wrong answer
In Quartz idiom you’ll sometimes see 0 0 0 ? * 1/2 offered for “every other week.” That fires on Sunday, Tuesday, Thursday and Saturday — Quartz numbers day-of-week 1–7 starting at Sunday, so the /2 step walks across days inside a single week, not across weeks. It is four runs a week, not one every two.
What actually works: weekly plus a parity gate
Schedule the job weekly with an ordinary cron expression, and let the job decide whether this week counts:
0 9 * * 1 /usr/local/bin/fortnightly.sh
#!/bin/sh
# Weeks elapsed since the Unix epoch, mod 2.
[ $(( ($(date -u +%s) / 604800) % 2 )) -eq 0 ] || exit 0
# ... the real work ...
The scheduling stays in cron where it belongs, and the “every other” part lives in one line of shell. Consecutive Mondays are exactly 604,800 seconds apart, so the counter increments by exactly one each week and the parity alternates without fail. Flip -eq 0 to -eq 1 to shift onto the opposite week.
Don’t use ISO week numbers for this
The gate you’ll find most often on the web uses the ISO week number instead:
# DON'T — breaks at some year boundaries.
[ $(( $(date +%V) % 2 )) -eq 0 ] || exit 0
ISO 8601 years have 53 weeks whenever January 1 falls on a Thursday, or on a Wednesday in a leap year. In those years week 53 is followed by week 01 — two odd numbers back to back — and a job gated on even weeks simply waits an extra fortnight:
2026-12-07 (W50) fires
2026-12-21 (W52) fires
2027-01-11 (W02) fires ← 21 days later, not 14
2027-01-25 (W04) fires
2026 is a 53-week year, so this is live right now, not a theoretical edge case. The epoch-based counter holds a clean 14-day gap across the same boundary. It anchors to a fixed instant rather than to a calendar that occasionally has an extra week in it.
One honest caveat: date -u +%s is absolute epoch seconds, so if your crontab runs in a local timezone that observes DST, two consecutive firings are 604,800 ± 3,600 seconds apart. That can only flip the parity if a firing lands within an hour of an epoch-week boundary — which falls on Thursday 00:00 UTC, because the Unix epoch itself began on a Thursday. Schedule the job in UTC, or on any day other than Thursday, and it cannot bite.
The alternative: stop asking for fortnightly
Most teams who ask for “every other week” want a predictable rhythm, not a mathematically exact 14-day gap. If that describes you, the 1st and 15th is a plain cron expression with no gating logic at all:
0 0 1,15 * * /usr/local/bin/semimonthly.sh
That’s semi-monthly, not fortnightly — 24 runs a year on fixed dates, with gaps between 13 and 17 days. For billing, payroll and invoicing that is usually the better fit anyway, because finance cares which date the run happened on, not how many days elapsed since the last one.
Schedulers that can express it directly
If you can move the schedule out of cron entirely, several runtimes handle a fortnightly interval natively:
- APScheduler —
IntervalTrigger(weeks=2), which sidestepsCronTrigger’s field rules completely. See the APScheduler guide. - Spring —
@Scheduled(fixedRate = 1_209_600_000L). Note that this counts from application start rather than the calendar, so a redeploy resets the phase. See the Spring@Scheduledguide. - Quartz — a
SimpleTriggerwith a 336-hour repeat interval.CronTriggercannot do it; see the Quartz dialect reference. - systemd — an
OnUnitActiveSec=2wtimer, which is monotonic and needs no calendar arithmetic. See systemd timers.
Each of these expresses an interval, which is what fortnightly actually is. Reach back for cron only when the runs need to land on specific calendar positions — and at that point you’re describing the 1st and 15th, not every other week.
Frequently asked questions
What is the cron expression for every other week?
Why doesn't `0 0 */14 * *` run every 14 days?
How many times a year does `0 0 */14 * *` actually fire?
What happens to `0 0 */14 * *` in a leap year?
Is `date +%V` week parity a safe way to schedule biweekly jobs?
Should I use the 1st and 15th instead?
More calendar schedules
Other patterns on the same cadence — or browse every schedule.