You need a job to run at 2:30 AM every weekday — but not on US holidays, and it needs to run in Eastern Time, not UTC. The first two requirements are straightforward with cron syntax. The third (holidays) isn't — and that misunderstanding sends developers down a cron rabbit hole that ends with "we'll handle it in the application."
Here's how cron expressions actually work, field by field.
The Five-Field Format
* * * * *
│ │ │ │ │
│ │ │ │ └─── Day of week (0-7, 0 and 7 both = Sunday)
│ │ │ └───── Month (1-12)
│ │ └─────── Day of month (1-31)
│ └───────── Hour (0-23)
└─────────── Minute (0-59)
Every field accepts:
*— any value ("every")5— specific value1-5— range*/2— step (every 2 units)1,15,30— list of values
The expression 30 2 * * 1-5 means: minute 30, hour 2, any day of month, any month, weekdays Monday through Friday. That's your 2:30 AM weekday job.
Use our Cron Expression Generator to build and test expressions visually before deploying them.
15 Common Expressions
| Expression | Meaning | Use case |
|---|---|---|
* * * * * |
Every minute | Heartbeat checks, rapid polling |
*/5 * * * * |
Every 5 minutes | Health checks, metrics collection |
0 * * * * |
Every hour on the hour | Hourly reports |
0 9 * * * |
Daily at 9:00 AM | Morning digest emails |
0 0 * * * |
Daily at midnight | Database cleanups, nightly backups |
30 2 * * * |
Daily at 2:30 AM | Off-peak batch processing |
0 9 * * 1-5 |
Weekdays at 9:00 AM | Business hour jobs |
0 0 * * 1 |
Every Monday at midnight | Weekly reports |
0 0 1 * * |
First of every month | Monthly billing runs |
0 0 1 1 * |
January 1st at midnight | Annual year-end processing |
0 8,12,17 * * 1-5 |
8 AM, noon, 5 PM on weekdays | Business hours notifications |
*/15 9-17 * * 1-5 |
Every 15 min during business hours | Peak-hours polling |
0 0 */7 * * |
Every 7 days from start of month | Rolling weekly jobs |
0 3 * * 0 |
Sundays at 3 AM | Low-traffic maintenance window |
5 4 * * * |
Daily at 4:05 AM | Offset from midnight to reduce server load |
10 Real-World Cron Job Examples
These are production-ready patterns with the reasoning behind each schedule choice:
1. Nightly database backup at 2 AM
0 2 * * *
Runs at 2 AM daily — low traffic, gives the backup time to complete before business hours start at 8 AM. If the backup takes 90 minutes, it finishes at 3:30 AM. Avoid midnight (0 0 * * *) because many other jobs compete for resources at midnight.
2. Weekly report email every Monday at 9 AM
0 9 * * 1
Monday morning at 9 AM — employees have the weekly summary when they open email. Use 1 for Monday (not 0 or 7 which are Sunday). Add MAILTO=reports@company.com to the crontab to route output to a specific address.
3. Health check every 15 minutes
*/15 * * * *
*/15 means "every 15 minutes from minute 0": runs at :00, :15, :30, :45 each hour. For more granular checks (every 5 minutes), use */5 * * * *. If you need sub-minute checks, cron is the wrong tool — use a monitoring service like Datadog or Pingdom.
4. First-of-month billing run at midnight
0 0 1 * *
Billing jobs run at midnight on the 1st — all accounts in the same billing cycle get processed together. Add error logging and an alert if the job doesn't complete within 2 hours (0 2 1 * * check_billing_completed.sh).
5. Weekday business hours only (9 AM to 5 PM)
0 9-17 * * 1-5
Runs at the top of each hour, 9 AM through 5 PM, Monday through Friday. Note: this runs AT 9 and AT 17 (5 PM), not between them — it's 10 runs per day. For jobs that shouldn't run outside business hours, combine with application-level checks.
6. Quarterly cleanup on the first day of each quarter
0 0 1 1,4,7,10 *
Runs at midnight on January 1, April 1, July 1, and October 10 — the first days of Q1, Q2, Q3, Q4. Use 1,4,7,10 in the month field. This pattern also works for quarterly reports, archival jobs, and index rebuilds.
7. Every 6 hours at :00
0 */6 * * *
Runs at midnight, 6 AM, noon, and 6 PM — four times daily. */6 in the hour field means "every 6 hours starting from hour 0." For staggered runs (e.g., 3 AM, 9 AM, 3 PM, 9 PM), use 0 3,9,15,21 * * * instead.
8. Year-end archive on December 31st
0 0 31 12 *
Runs at midnight on December 31st only. December always has 31 days so this is safe. For year-end jobs that can't wait, this fires right at the start of the last day. If your archive job takes hours, schedule it at 0 20 31 12 * (8 PM Dec 31) so it completes by midnight.
9. Last day of month workaround Cron can't directly express "last day of month." The standard workaround is a wrapper script:
#!/bin/bash
# Last day of month: run if tomorrow is the 1st
if [ "$(date +\%d -d tomorrow)" = "01" ]; then
/usr/bin/python3 /opt/jobs/month_close.py
fi
Schedule this wrapper daily at midnight: 0 0 28-31 * * — it runs on the 28th–31st, but only executes when tomorrow is the 1st.
10. Every other Sunday maintenance window
0 3 * * 0
Standard cron can't express "every other Sunday" — it's every Sunday or nothing. Two approaches: (1) run weekly and have the script check $(date +\%V) for odd/even week number; (2) use a scheduler that supports more complex recurrence (AWS EventBridge, GCP Cloud Scheduler, Kubernetes CronJob with a custom controller).
Shortcut Syntax
Most cron implementations support shorthand macros:
| Shorthand | Equivalent | Description |
|---|---|---|
@reboot |
N/A | Run once at system startup |
@yearly or @annually |
0 0 1 1 * |
Once a year, January 1 |
@monthly |
0 0 1 * * |
Once a month, first day |
@weekly |
0 0 * * 0 |
Once a week, Sunday midnight |
@daily or @midnight |
0 0 * * * |
Once a day at midnight |
@hourly |
0 * * * * |
Every hour |
@reboot is particularly useful for starting services or running one-time initialization tasks. It runs the command once when the cron daemon starts, which typically means on system boot.
The Day-of-Week vs. Day-of-Month Edge Case
When you specify both day-of-month and day-of-week with non-wildcard values, cron uses OR logic — the job runs when either condition matches.
0 0 1 * 5 means: midnight on the first of the month OR every Friday at midnight. Not: midnight on Fridays that fall on the first. This surprises most developers who write it expecting AND logic.
To get AND behavior (Friday the 1st), you need to handle it in your script:
#!/bin/bash
# Only run if today is Friday AND the 1st
day_of_week=$(date +%u) # 5 = Friday
day_of_month=$(date +%d) # 01-31
if [ "$day_of_week" = "5" ] && [ "$day_of_month" = "01" ]; then
echo "Friday the 1st — running job"
# your command here
fi
Common Mistakes
1. Minute vs. hour field confusion
The most common mistake: writing 30 * * * * when you want "daily at 30 minutes past midnight." That expression actually runs every hour at :30, not once a day. For daily at 12:30 AM, use 30 0 * * *. The fields go minute first, then hour — the reverse of how most people read time.
2. Forgetting PATH environment variable
Cron runs with a minimal environment. PATH is typically /usr/bin:/bin. If your script calls commands like python3, node, aws, docker, these may not be in the cron PATH. Fix: use full absolute paths (/usr/bin/python3, /usr/local/bin/node) or source your profile at the top of the script: . /etc/profile.
3. Not handling timezone differences
System cron uses the server's timezone. Most cloud VMs default to UTC. A job set to 0 9 * * * runs at 9 AM UTC — which is 4 AM or 5 AM US Eastern, not 9 AM. Always document what timezone a cron expression uses. For cloud schedulers, check the docs: AWS EventBridge is UTC-only; Kubernetes 1.27+ supports a timeZone field.
4. Not logging cron job output
Default behavior: stdout goes to /dev/null, errors get emailed to the local user. Both mean silent failures. Minimum viable logging:
0 2 * * * /opt/jobs/nightly.py >> /var/log/nightly.log 2>&1
The 2>&1 redirects stderr to the same log file. Add a timestamp to each log entry with echo "$(date) - job started" at the top of your script. For production jobs, send logs to your observability stack and alert if the "job completed" log entry hasn't appeared within N minutes of the schedule.
5. Over-relying on cron for critical jobs
Cron has no built-in retry, no failure notification beyond email, and no dependency management. For jobs that must succeed (billing, data pipelines, compliance reports), use a proper workflow scheduler like Airflow, Prefect, or AWS Step Functions. These provide retries, alerting, dependency chains, and audit logs.
Timezone Gotchas
System cron runs in the server's local timezone by default. If your server is in UTC (common for cloud VMs), a job set to run at 0 9 * * * runs at 9:00 AM UTC — which is 4:00 AM US Eastern Time in summer and 5:00 AM in winter.
To specify timezone in system crontab (not universally supported — check your cron implementation):
CRON_TZ=America/New_York
0 9 * * 1-5 /usr/bin/python3 /opt/jobs/morning_report.py
AWS CloudWatch Events / EventBridge: Supports cron expressions in UTC only. No timezone support. Convert all times to UTC manually.
Kubernetes CronJob: Uses UTC by default. Set timeZone: "America/New_York" in the CronJob spec (Kubernetes 1.27+):
apiVersion: batch/v1
kind: CronJob
spec:
schedule: "30 2 * * 1-5"
timeZone: "America/New_York"
GitHub Actions: Scheduled workflows run in UTC. cron: '30 7 * * 1-5' = 7:30 AM UTC = 3:30 AM Eastern. Add 4-5 hours to your intended local time when writing the YAML.
Holidays: What Cron Can't Do
Cron has no concept of business calendars, holidays, or "last business day of the month." For jobs that need to skip holidays:
Option 1: Check in the script — Write your job to query a holiday API or internal calendar at runtime and exit early if it's a holiday.
Option 2: Use a workflow scheduler — Tools like Airflow, Prefect, and Temporal have native calendar-aware scheduling. They're significantly more complex than cron but handle these edge cases correctly.
Option 3: Use a hosted scheduler — Services like EasyCron, Cronhooks, or cloud-native schedulers (AWS Step Functions, GCP Cloud Scheduler) often have richer scheduling logic than raw cron.
Preventing Overlapping Job Runs
A common cron failure mode: the scheduled job takes longer than its interval to complete. If a job runs every 5 minutes but occasionally takes 7 minutes, two instances run simultaneously. For jobs that modify databases or files, concurrent runs cause corruption or duplicate processing.
The safest fix is a locking mechanism. On Linux, flock serializes cron job runs:
# Only runs one instance at a time; skips if already running
*/5 * * * * flock -n /tmp/myjob.lock /usr/bin/python3 /opt/jobs/process.py
The -n flag (non-blocking) causes flock to exit immediately if the lock is held, rather than waiting. This means a slow run is skipped rather than piled up — appropriate for most ETL and reporting jobs.
For AWS Lambda and containerized jobs (Kubernetes CronJob, ECS Scheduled Tasks), the scheduler itself handles concurrency: set concurrencyPolicy: Forbid in your Kubernetes CronJob spec to skip a run if the previous one is still active.
Logging and Alerting
Default cron behavior: redirect stdout to /dev/null and errors are emailed to the system user. This means silent failures — the job runs, something goes wrong, and you find out by noticing stale data three days later.
Minimum viable logging:
# Append stdout and stderr to a log file with timestamp
0 2 * * * /usr/bin/python3 /opt/jobs/nightly.py >> /var/log/nightly.log 2>&1
For production jobs: use a structured logging approach and push logs to your observability stack (Datadog, CloudWatch, etc.). Set an alert if the log entry indicating successful completion hasn't appeared within N minutes of the scheduled time. Job scheduler monitoring — knowing a job didn't run, not just that it failed — is the part most teams skip and regret.
Five-Field vs. Six-Field Expressions
Standard Unix cron uses 5 fields (minute, hour, day-of-month, month, day-of-week). Some systems use 6-field or 7-field extensions:
6-field with seconds (first position): Used by Quartz Scheduler (Java), Spring @Scheduled, and some cloud schedulers. 0 30 2 * * ? = 2:30 AM daily (first 0 is seconds).
6-field with year (last position): Less common. 0 30 2 * * * 2026 = 2:30 AM daily in 2026 only.
If a cron expression that looks correct isn't running when expected, check whether your scheduler uses 5-field or 6-field format. Pasting a 5-field expression into a 6-field scheduler silently shifts all field meanings by one position.
Raw cron is the right tool for time-based periodic jobs with no calendar dependencies. Use our Cron Expression Generator to build your expression visually and preview the next 5 run times before deploying to production.
Cron Expression Generator
Build cron expressions visually and preview the next 5 run times with human-readable descriptions.