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.
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,15in the minute field fires at minute 1 and minute 15.-(dash): a range.9-17in the hour field means every hour from 9 AM through 5 PM inclusive./(slash): a step.*/5in the minute field fires every 5 minutes (0, 5, 10, ..., 55).10/15fires 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 |
|---|---|
@yearly | 0 0 1 1 * |
@monthly | 0 0 1 * * |
@weekly | 0 0 * * 0 |
@daily | 0 0 * * * |
@hourly | 0 * * * * |
@reboot | Once 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 minutes | Health check pings |
| 2 | 0 * * * * | Top of every hour | Metrics aggregation |
| 3 | */15 * * * * | Every 15 minutes | Cache warm-up |
| 4 | 30 2 * * * | 2:30 AM daily | Database backup |
| 5 | 0 6 * * 1-5 | 6 AM weekdays | Morning status report |
| 6 | 0 0 * * 0 | Midnight Sunday | Weekly log rotation |
| 7 | 0 9 1 * * | 9 AM on the 1st | Monthly billing report |
| 8 | 0 0 1 1 * | Midnight Jan 1 | Annual license renewal |
| 9 | 0 */6 * * * | Every 6 hours | SSL cert check |
| 10 | 0 8,12,18 * * * | 8 AM, noon, 6 PM | Notification digest |
| 11 | 15 3 * * 6 | 3:15 AM Saturday | Full disk cleanup |
| 12 | 0 22 * * 1-5 | 10 PM weekdays | End-of-day DB vacuum |
| 13 | */10 9-17 * * 1-5 | Every 10 min, business hours | Queue depth monitor |
| 14 | 0 0 15 * * | Midnight on the 15th | Mid-month invoice run |
| 15 | 5 4 * * 0 | 4:05 AM Sunday | Weekly full backup |
| 16 | 0 3 1,15 * * | 3 AM on 1st and 15th | Semi-monthly payroll export |
| 17 | 0 */2 * * * | Every 2 hours | CDN cache purge |
| 18 | 30 1 * * * | 1:30 AM daily | ETL pipeline trigger |
| 19 | 0 0 * * 1 | Midnight Monday | Start-of-week analytics |
| 20 | 45 23 * * * | 11:45 PM daily | Pre-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 complexity | Low (one line) | Medium (two unit files) | Medium (console/IaC) |
| Missed run handling | None (silently skipped) | Persistent=true catches up | Retry policies available |
| Logging | Syslog/mail | journald (structured) | CloudWatch/Cloud Logging |
| Dependency management | None | After=, Requires= | Event chaining |
| Seconds precision | No (minute minimum) | Yes (OnCalendar) | Yes (rate expressions) |
| Overlap prevention | Manual (flock) | Built-in | Varies 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:
- Use UTC. Set
TZ=UTCat the top of the crontab. Future-you will thank present-you when DST rolls around. - Redirect output. Append
>> /var/log/myjob.log 2>&1to every job. Cron's default behavior is to email output, and if the mail system isn't configured, that output vanishes. - Use
flockfor 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-nflag makes it fail immediately instead of waiting. - Set
MAILTO. Even if you redirect output,MAILTO=ops@example.comcatches unexpected failures that slip past your redirects. - Use full paths. Cron runs with a minimal
PATH./usr/bin/python3instead of justpython3. Or definePATH=at the top of your crontab. - 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.
- 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 * * *and30 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 fromdocker run -ewon'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.