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.
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 |
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
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 the Cron Expression Generator to build your expression and preview the next 5 run times before deploying.
Cron Expression Generator
Build cron expressions visually and preview the next 5 run times with human-readable descriptions.