DevOps March 19, 2026 8 min read

Cron Expressions Demystified: Basics to Production

Learn cron syntax from five-field basics to timezone traps and real-world scheduling patterns. Includes 20 production-tested expressions.

Every developer has a cron story. Mine is the backup job that ran at 2:30 AM daily — except on the day Daylight Saving Time kicked in, when it ran twice. The database locked for 40 minutes. The on-call engineer (also me) got paged at 2:31 AM to fix a problem caused by past-me's confidence that 30 2 * * * was simple enough to not need a second look.

Cron expressions look simple. Five fields, a handful of special characters, done. But the gap between reading a cron tutorial and running cron in production is where backup jobs double-fire, billing reports skip months, and log rotation silently stops. This guide covers the syntax you need, the 20 expressions you'll copy-paste most often, and the traps that catch everyone at least once.

* * * * * minute hour day (month) month day (week)

The Five Fields and What They Actually Mean

Every standard cron expression has five fields, read left to right:

┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12 or JAN-DEC)
│ │ │ │ ┌───────────── day of week (0-7, 0 and 7 = Sunday, or SUN-SAT)
│ │ │ │ │
* * * * *  command-to-run

That's it. No seconds field (that's a Quartz/Spring extension), no year field (also an extension). Classic cron is five fields and a command.

Special characters

  • * (asterisk): matches every value in the field. * * * * * runs every minute.
  • , (comma): a list. 1,15 in the minute field fires at minute 1 and minute 15.
  • - (dash): a range. 9-17 in the hour field means every hour from 9 AM through 5 PM inclusive.
  • / (slash): a step. */5 in the minute field fires every 5 minutes (0, 5, 10, ..., 55). 10/15 fires at 10, 25, 40, 55.

You can combine them: 0 9-17/2 * * 1-5 means "at minute 0, every 2 hours from 9 through 17, Monday through Friday." That's a reasonable schedule for a health-check ping during business hours.

Common shortcuts

Most cron implementations also support these convenience strings:

Shortcut Equivalent
@yearly0 0 1 1 *
@monthly0 0 1 * *
@weekly0 0 * * 0
@daily0 0 * * *
@hourly0 * * * *
@rebootOnce at startup

20 Expressions You'll Actually Use

Rather than listing every theoretical combination, here are the ones I keep coming back to across different teams and stacks. Copy-paste and adjust as needed.

# Expression Meaning Use case
1*/5 * * * *Every 5 minutesHealth check pings
20 * * * *Top of every hourMetrics aggregation
3*/15 * * * *Every 15 minutesCache warm-up
430 2 * * *2:30 AM dailyDatabase backup
50 6 * * 1-56 AM weekdaysMorning status report
60 0 * * 0Midnight SundayWeekly log rotation
70 9 1 * *9 AM on the 1stMonthly billing report
80 0 1 1 *Midnight Jan 1Annual license renewal
90 */6 * * *Every 6 hoursSSL cert check
100 8,12,18 * * *8 AM, noon, 6 PMNotification digest
1115 3 * * 63:15 AM SaturdayFull disk cleanup
120 22 * * 1-510 PM weekdaysEnd-of-day DB vacuum
13*/10 9-17 * * 1-5Every 10 min, business hoursQueue depth monitor
140 0 15 * *Midnight on the 15thMid-month invoice run
155 4 * * 04:05 AM SundayWeekly full backup
160 3 1,15 * *3 AM on 1st and 15thSemi-monthly payroll export
170 */2 * * *Every 2 hoursCDN cache purge
1830 1 * * *1:30 AM dailyETL pipeline trigger
190 0 * * 1Midnight MondayStart-of-week analytics
2045 23 * * *11:45 PM dailyPre-midnight audit snapshot

Expressions #4 and #18 intentionally avoid the top of the hour. When dozens of jobs all fire at 0 2 * * *, you get resource contention. Staggering by even a few minutes helps more than you'd expect.

The Traps: Edge Cases That Bite

The syntax is small; the footguns are not. Here are the ones that have burned me or teammates in real systems.

Day of month + day of week = OR, not AND

This is probably the single most misunderstood behavior in cron. If you write:

0 9 15 * 1
# "Run at 9 AM on the 15th AND on Mondays"? WRONG.
# Runs at 9 AM on the 15th OR on any Monday.

When both day-of-month and day-of-week are non-*, cron treats them as a union, not an intersection. The crontab(5) man page spells this out, but nearly everyone assumes AND on the first read. If you need "the 15th, but only when it's a Monday," you'll have to add a check inside the script itself: [ "$(date +%u)" = "1" ] || exit 0.

February 31st and other phantom dates

Cron won't complain if you schedule something for 0 0 31 2 *. It just won't run. Ever. Similarly, 0 0 31 * * skips February, April, June, September, and November. If you're scheduling end-of-month work, you're better off running on day 1 of the next month and processing the previous month's data. Or use 0 0 28-31 * * with a script guard that checks whether tomorrow is the 1st.

Timezones and Daylight Saving Time

Back to my opening story. Here's what happens with DST in practice:

  • Spring forward (e.g., 2:00 AM becomes 3:00 AM): jobs scheduled between 2:00 and 2:59 never run that day.
  • Fall back (e.g., 2:00 AM repeats): jobs scheduled in that hour may run twice, depending on the cron implementation.

The fix is straightforward: set the cron daemon's timezone to UTC. On most Linux systems, put TZ=UTC at the top of your crontab or set the system's timezone. UTC doesn't observe DST, so 2:30 AM always exists exactly once.

@reboot in containers

@reboot fires when the cron daemon starts, not when the OS boots. In a Docker container, that means it fires every time the container starts. If your container restarts due to a health check failure, @reboot fires again. And again. For initialization tasks in containers, an entrypoint script is almost always more predictable.

Cron vs Systemd Timers vs Cloud Schedulers

Cron isn't the only game in town. Here's how the main options compare:

Feature Cron Systemd Timers Cloud (AWS EventBridge, GCP Scheduler)
Setup complexityLow (one line)Medium (two unit files)Medium (console/IaC)
Missed run handlingNone (silently skipped)Persistent=true catches upRetry policies available
LoggingSyslog/mailjournald (structured)CloudWatch/Cloud Logging
Dependency managementNoneAfter=, Requires=Event chaining
Seconds precisionNo (minute minimum)Yes (OnCalendar)Yes (rate expressions)
Overlap preventionManual (flock)Built-inVaries by service

For a deeper look at systemd timer syntax, the systemd.timer documentation covers calendar events and monotonic timers in detail. If you're already managing infrastructure as code and your jobs are serverless functions or containers, cloud schedulers are usually the better choice. If you're on a single VM and need something running in five minutes, cron is still hard to beat.

Production Checklist

Before you commit a crontab change and go home for the day, run through this list:

  1. Use UTC. Set TZ=UTC at the top of the crontab. Future-you will thank present-you when DST rolls around.
  2. Redirect output. Append >> /var/log/myjob.log 2>&1 to every job. Cron's default behavior is to email output, and if the mail system isn't configured, that output vanishes.
  3. Use flock for overlap prevention. If a job takes longer than its interval, the next invocation starts on top of it. Wrap it: flock -n /tmp/myjob.lock /path/to/script.sh. The -n flag makes it fail immediately instead of waiting.
  4. Set MAILTO. Even if you redirect output, MAILTO=ops@example.com catches unexpected failures that slip past your redirects.
  5. Use full paths. Cron runs with a minimal PATH. /usr/bin/python3 instead of just python3. Or define PATH= at the top of your crontab.
  6. Monitor externally. Use a dead man's switch service (Cronitor, Healthchecks.io, or a simple webhook ping). If the job doesn't call in, you get an alert. This catches the failure mode that logging alone misses: the job that silently stops running.
  7. Test with a dry run. Before scheduling a job hourly, schedule it one minute from now, verify it works, then change to the real schedule.

A solid crontab header looks something like this:

TZ=UTC
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=ops@example.com

# Database backup - daily at 02:30 UTC
30 2 * * * flock -n /tmp/db-backup.lock /opt/scripts/db-backup.sh >> /var/log/db-backup.log 2>&1

# Health check ping - every 5 minutes
*/5 * * * * curl -fsS --retry 3 https://hc-ping.com/your-uuid > /dev/null

If you want to quickly count characters in your crontab comments or script paths, our Text Counter tool handles that in a click.

Frequently Asked Questions

Is */5 different from 0,5,10,15,20,25,30,35,40,45,50,55?

Functionally, no. They produce the same schedule. The slash notation is just shorthand. Some older cron implementations on obscure systems didn't support /, which is why you'll occasionally see the explicit comma-separated list in legacy crontabs. On any modern system, use */5 for readability.

Can I schedule a job every 90 minutes?

Not directly. Cron's minute field resets every hour, so there's no way to express "every 90 minutes" in a single expression. Your options:

  • Use two cron entries that together approximate it: 0 0,3,6,9,12,15,18,21 * * * and 30 1,4,7,10,13,16,19,22 * * *. This gives you every 90 minutes on a 24-hour cycle.
  • Run the job every minute and have the script check elapsed time since its last successful run (using a timestamp file or database flag).
  • Switch to a systemd timer with OnUnitActiveSec=90min, which handles arbitrary intervals natively.

How do I list all scheduled cron jobs on a system?

There's no single command that shows everything, because cron jobs live in multiple places:

# Current user's crontab
crontab -l

# Another user's crontab (requires root)
crontab -u username -l

# System-wide cron directories
ls /etc/cron.d/
ls /etc/cron.daily/
ls /etc/cron.hourly/

# The main system crontab
cat /etc/crontab

Don't forget to check /etc/anacrontab if the system uses anacron for catch-up scheduling.

How do I run cron in Docker?

The short answer: carefully. The cron daemon expects to be PID 1 or at least a long-running process, and Docker containers are usually built around a single foreground process. Common approaches:

  • Cron as PID 1: Install cron in the image, copy your crontab in, and set CMD ["cron", "-f"] to run in the foreground. Environment variables from docker run -e won't be available inside cron jobs unless you explicitly dump them to a file the job sources.
  • External scheduler: Skip cron entirely. Use Kubernetes CronJobs, ECS Scheduled Tasks, or a sidecar scheduler like supercronic (which is designed for containers and logs to stdout).
  • Entrypoint wrapper: Start cron as a background process in an entrypoint script alongside your main process. This works but makes health checks and signal handling more fragile.

For most containerized workloads, the external scheduler approach is the cleanest. It separates scheduling from execution and gives you better observability.

Wrapping Up

Cron has been around since the 1970s, and its core syntax hasn't changed much because it doesn't need to. Five fields, a few special characters, and a command. The hard part was never the syntax; it's the operational details around it: timezones, overlap, logging, monitoring, and understanding what happens when reality doesn't match your mental model of the schedule.

If you're working with scheduled jobs as part of a larger config-file-heavy workflow, getting the syntax right in one place often prevents cascading issues in another. Keep your crontabs commented, your timezones explicit, and your dead man's switches armed.