All posts

Cron Expressions: Writing Schedules That Actually Run When You Expect

4 min read

Stop guessing why your cron jobs run at 3 AM instead of 3 PM. Learn how cron evaluates time, handles timezones, and why concurrency breaks silently.

You write 0 */6 * * *, deploy, and check the logs the next day. The job ran at 3:00, 9:00, 15:00, 21:00 exactly. Or it ran at 3:00 and 9:00 and then stopped. Cron handled the syntax correctly, but "correctly" in cron doesn't always mean "as you intended." Understanding how a cron expression is evaluated is different from knowing the field positions.

The five fields are easy to learn. The evaluation rules (documented in the POSIX spec for crontab) are where production issues come from.

Day of Month and Day of Week Are ORed

A standard cron expression has five fields: minute, hour, day of month, month, day of week. All conditions are ANDed together except day of month and day of week, which are ORed.

0 0 * * 0 runs at midnight every Sunday. One condition, clear.

0 0 1 * 0 does not run on the first Sunday. It runs on the first day of the month OR on any Sunday. If the 1st is a Saturday and the 2nd is a Sunday, it fires twice in two days.

This table shows common expressions that look similar but behave differently:

Expression What It Actually Means What People Assume
0 0 * * 0 Midnight every Sunday Same
0 0 1 * 0 1st of month OR any Sunday First Sunday of month
*/15 * * * * Every 15 minutes by the clock Every 15 minutes from job start
0 0 1 */2 * 1st of every other month Same
30 8 * * 1-5 8:30 AM weekdays Same
0 */2 * * * Every 2 hours at :00 past Every 2 hours from job start

The last three work as most people expect. The first three behave differently because of the OR rule or the clock-aligned behavior of */N. */15 fires at :00, :15, :30, :45, not at whatever minute you added the job. If a cron job runs more often than expected, check whether day-of-month and day-of-week are both set.

Timezone Is Part of the Expression

Cron reads the system clock. Not UTC, not your application's configured timezone. If the server uses UTC and your users are in EST, a 9 AM cron job fires at 4 AM user time.

# Check what timezone cron uses
cat /etc/timezone

# Or set it explicitly at the top of the crontab
TZ=America/New_York
0 3 * * * /usr/local/bin/generate-report

Not every cron implementation respects the TZ variable. Vixie cron (standard on Linux) does. BusyBox cron (common in Alpine-based containers) doesn't. I've seen this cause issues in containerized deployments where the same Dockerfile produces a different cron behavior depending on the base image.

Adding date output to cron job logs is a cheap way to verify: pipe the command output and check the timestamp against your expectations.

Concurrency: The Silent Corruption

A cron job that takes 8 minutes and runs every 5 will eventually overlap. Two instances modify the same database table, and the second one works with stale state.

# Atomic lock with mkdir. Add to the top of your script.
LOCKFILE="/tmp/myjob.lock"
if ! mkdir "$LOCKFILE" 2>/dev/null; then
    echo "Job still running"
    exit 1
fi
trap 'rm -rf "$LOCKFILE"' EXIT

Using mkdir for locking is atomic on Unix. The trap handler cleans up on crash, and the lock persists for the job's full duration. It's simpler than managing PID files and more portable than flock. The same approach translates to most scripting languages: check for the lock file at the start, then create and release it around the critical section.

Clock Drift and DST

Two situations where cron behaves unexpectedly that rarely get caught in development.

Server clock drift. A machine that loses 30 seconds per day shifts a */5 * * * * job by half a minute every 5 days. After two months the job is 6 minutes off. NTP should correct this, but isolated networks don't always have reliable NTP. The logs show "1:57" and nobody flags it because the server agrees with itself. The mismatch only surfaces when comparing timestamps across systems.

DST transitions. When clocks spring forward, the 2 AM job disappears. When they fall back, it runs twice. Cron implementations handle this inconsistently: some skip the duplicate, some run it twice. The only reliable fix is running cron in UTC and converting timezones in the application layer. Your database timestamps should be UTC too. If they're stored in local time, a DST transition can duplicate or skip records during clock changes.

The cron parser on this site shows the next 10 execution times for any expression. When you're debugging an unexpected schedule, paste the expression and check the list. The mismatch between intent and evaluation is usually visible in the first few runs.

Cron is reliable at what it does. Problems come from the boundaries between the expression, the system clock, and the job's runtime. Each piece is simple. The interaction between them produces failures that look like cron bugs but aren't.

OCMA Tools

Free developer tools. Most features run client-side, your data stays in your browser. Optional accounts unlock extra features.

Most tools run client-side

© 2026 OCMA Tools — Free developer tools

built for developers, by developers