Cron Expressions Explained: Syntax, Gotchas, and Production-Grade Schedules
The five fields, the special characters, and the parts nobody tells you: why day-of-month and day-of-week combine with OR, why jobs silently die from a bare-bones PATH, what happens to your 2:30 AM job on DST nights, and how AWS, GitHub Actions, and Kubernetes each break standard cron syntax differently.
Cron Expressions Explained: Syntax, Gotchas, and Production-Grade Schedules
Cron is fifty years old and still schedules most of the world's recurring work β backups, report generation, certificate renewals, cache warming, that one cleanup script nobody remembers writing. The syntax takes ten minutes to learn. What takes longer is discovering, one incident at a time, the behaviors nobody mentioned: the job that ran 56 times a year instead of 2, the backup that silently stopped when it moved to a container, the 2:30 AM task that vanished one Sunday in March.
This is the ten-minute syntax lesson plus the incidents, so you can skip having them.
The five fields
A cron expression is five space-separated fields, left to right from smallest unit to largest:
ββββββββββββββ 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 are both Sunday, or SUNβSAT)
β β β β β
* * * * * command to run
Each field takes:
| Syntax | Meaning | Example |
|---|---|---|
* | every value | * in hours = every hour |
5 | exactly this value | 5 in minutes = at :05 |
1,15 | this list of values | 1st and 15th of the month |
9-17 | this range | 9 AM through 5 PM |
*/10 | every Nth value | every 10 minutes |
9-17/2 | every Nth within a range | 9, 11, 13, 15, 17 |
Read right-to-left for the plain-English version: 30 3 * * 1 β Mondays (1), any month, any day-of-month, at 03:30. When an expression matters β meaning someone gets paged if you misread it β don't trust your own parsing. Our cron parser renders any expression as plain English with the next run times, which catches transposed fields instantly. (Everyone eventually writes 3 30 * * * for 30 3 * * *. That one asks for hour 30, which doesn't exist, so it never fires at all.)
The schedules you'll actually write:
| Expression | Schedule |
|---|---|
*/5 * * * * | every 5 minutes |
0 * * * * | every hour, on the hour |
0 3 * * * | daily at 03:00 |
0 3 * * 1-5 | weekdays at 03:00 |
0 9 1 * * | first of the month, 09:00 |
0 0 1 1 * | once a year, Jan 1 midnight |
0 8,13,18 * * * | 08:00, 13:00, and 18:00 daily |
Most crons also accept shorthands: @hourly, @daily, @weekly, @monthly, @yearly, and the odd one out, @reboot, which runs once at daemon startup β handy for "start my tunnel when the box boots," with the caveat that it also fires when merely the cron service restarts on some systems.
The special characters, one at a time
Two things the table above doesn't tell you. Ranges are inclusive at both ends, so 0 9-17 * * * gives nine runs, not eight. And a step is anchored to the field's range, not to the current time β */40 * * * * fires at :00 and :40 and then wraps back to :00, a 20-minute gap once an hour rather than "every 40 minutes". Only divisors of 60 behave the way the phrase "every N minutes" implies.
Three further characters turn up in cloud consoles and Java configs, and standard Linux cron rejects all of them. They come from Quartz; AWS EventBridge inherited them.
L means "last" β the last day of the month in day-of-month, and the last such weekday of the month in day-of-week:
0 0 L * ? # midnight on the 31st in January, the 28th or 29th in February
0 22 ? * 5L # 22:00 on the last Friday of every month
W means "nearest weekday" to a given day-of-month, and never crosses a month boundary. 15W fires on the 15th when that's MonβFri; a Saturday 15th moves back to Friday the 14th, a Sunday 15th forward to Monday the 16th. LW is the last weekday of the month.
0 9 15W * ? # payroll: 09:00 on the nearest weekday to the 15th
# means "the Nth of that weekday in the month".
0 14 ? * 6#3 # 14:00 on the third Friday of every month
Look closely at that 6. Quartz and EventBridge number the week 1β7 starting at Sunday, so Friday is 6 β one higher than standard cron, where Friday is 5. Nothing errors; the job just runs a day late, every week, until someone notices. Write FRI#3 and the problem evaporates.
? means "I'm not constraining this field", and it exists only in the two day fields. Quartz-family schedulers refuse to guess how those fields combine, so they make you blank one out β which is why every example above has one. Standard cron doesn't force the choice, and that turns out to be a problem.
Installing your first crontab
Expressions live in a crontab β a plain text file, one job per line, owned by a specific user. You don't edit it directly; you go through the crontab command, so the daemon hears about the change. Open yours with crontab -e.
The first time you run that on a machine, two things happen that look like errors and aren't. First, the editor picker:
Select an editor. To change later, run 'select-editor'.
1. /bin/nano <---- easiest
2. /usr/bin/vim.basic
3. /usr/bin/vim.tiny
4. /bin/ed
Choose 1-4 [1]:
Press Enter for nano unless you have opinions. Second, above the file contents:
no crontab for alice - using an empty one
That's cron creating your crontab, not refusing to. Add a line at the bottom β this one writes a timestamp every minute, so you get feedback fast:
* * * * * /bin/date >> /tmp/cron-test.log 2>&1
Save and exit (in nano: Ctrl+O, Enter, Ctrl+X). Cron confirms with exactly one line:
crontab: installing new crontab
If you don't see that line, your job is not installed. A syntax error produces errors in crontab file, can't install and offers to re-edit β take the offer, or your edits are discarded. Then confirm what's loaded and, a couple of minutes later, that it ran:
crontab -l
cat /tmp/cron-test.log
Two or three timestamps, one per minute, means the whole chain works: daemon running, expression valid, command found, output landing where you told it. Establish that baseline before you debug a real job β it separates "cron is broken" from "my script is broken".
Two warnings. crontab -r deletes your entire crontab with no confirmation prompt, and it sits one keypress from -e β run crontab -l > ~/crontab.bak before editing. And crontabs are per-user: sudo crontab -u www-data -l is a different file from yours, which is why so many "it isn't running" jobs turn out to be installed under the wrong account.
System crontabs are the exception. Files in /etc/cron.d/ and /etc/crontab are edited with a normal editor and carry a username between the schedule and the command:
# /etc/cron.d/backup β note the extra 'root' column
30 3 * * * root /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
Omit it and cron rejects the line. And a Debian-ism worth knowing: filenames in /etc/cron.d/ may contain only letters, digits, underscores and hyphens. Call it backup.sh and the file is ignored entirely, without a word of complaint.
The OR trap: day-of-month vs. day-of-week
Here's the rule that catches everyone exactly once. You want a job on Friday the 13th, so you write:
0 0 13 * 5 # midnight, 13th of the month, Friday... right?
Wrong β that runs on every 13th and every Friday. When both day fields are restricted (neither is *), POSIX cron combines them with OR, not AND. It's documented, it's ancient, and it still produces ~56 runs a year where someone expected 2. If a report "randomly" arrives on days it shouldn't, check this before anything else. Three cases, and that's the whole rule:
| Day-of-month | Day-of-week | Runs on |
|---|---|---|
13 | * | the 13th only |
* | 5 | Fridays only |
13 | 5 | the 13th or any Friday |
The workaround: keep one field in cron, check the other in the command. Leave day-of-week as * so cron only handles the 13th, then let the shell throw away any 13th that isn't a Friday:
0 0 13 * * [ "$(date +\%u)" = 5 ] && /path/to/friday13-job
date +%u prints the ISO day of week, 1 for Monday through 7 for Sunday, so 5 is Friday. Note the backslash before the %: inside a crontab a bare percent sign isn't a percent sign, it's a newline.
This is exactly the ambiguity ? was invented to kill β Quartz makes you blank one day field, so there's nothing left to combine. Which brings us to the dialect problem.
Every platform speaks a slightly different cron
Copy-pasting an expression between systems without checking the dialect is a classic way to be wrong quietly:
- AWS EventBridge uses six fields β it appends a year β requires
?in one day field, supportsLandW, and evaluates in UTC unless configured otherwise. Standard five-field expressions are rejected or reinterpreted. - GitHub Actions uses standard five fields but is UTC-only, has a floor of 5 minutes, and treats the schedule as best effort. High-load periods (the top of every hour especially) routinely delay workflows by many minutes, and runs can be skipped entirely. Never put anything time-sensitive on it β and note that scheduled workflows on repos idle for 60 days get disabled automatically.
- Kubernetes CronJob uses standard five fields, defaults to the controller's timezone, and since v1.27 supports an explicit
timeZone: "Etc/UTC"field β set it, always.startingDeadlineSecondsandconcurrencyPolicy: Forbidare Kubernetes' answers to missed runs and overlap. - Quartz (Java land) uses six or seven fields starting with seconds, so a copied five-field expression shifts every value one position left. It fails loudly, at least.
- systemd timers aren't cron at all. They use their own calendar syntax (
OnCalendar=Mon..Fri 03:00) and their own semantics, so there's nothing to port β you rewrite the schedule.
The five-field expression is the lingua franca; just confirm what accent the destination speaks before deploying.
Why your job runs in your terminal but not in cron
The number-one cron question online isn't about syntax at all β it's "my job doesn't run." Ninety percent of the time it's one of these four, in this order:
1. The environment isn't your environment. Cron launches commands with a nearly empty environment: PATH is typically /usr/bin:/bin, SHELL is /bin/sh (not bash), and none of your .bashrc or .profile setup exists. So node script.js works at your prompt and produces node: command not found in cron β except you never see that message (see #3). Anything installed by nvm, pyenv, rbenv, asdf or Homebrew is invisible here, because all of those work by editing your shell profile, and cron doesn't read shell profiles.
Two fixes, and you want both: absolute paths for the interpreter and the script, plus an explicit environment declared above the job lines.
PATH=/usr/local/bin:/usr/bin:/bin
SHELL=/bin/bash
MAILTO=""
30 3 * * * /usr/local/bin/node /srv/app/scripts/report.js >> /var/log/report.log 2>&1
Those assignments are crontab syntax, not shell syntax β no export, no quotes β and they apply to every line below them. MAILTO="" turns off cron's mail attempts, which is what you want once output goes to files. Get the absolute path for anything you're unsure of with command -v node.
2. The percent sign. An unescaped % is a newline, and everything after it becomes stdin to the command β so date +%Y-%m-%d breaks in a genuinely baffling way. Escape it: date +\%Y-\%m-\%d.
3. Output goes nowhere you look. Cron mails stdout and stderr to the user's local mailbox, which on a modern server is a file nobody has opened since the box was provisioned. Until you append >> /var/log/myjob.log 2>&1, every failure is invisible. First thing to add when debugging, not the last.
4. Crontab formalities. Wrong user, missing sixth column β both look exactly like "the job isn't running". And some implementations still ignore a crontab's final line if the file lacks a trailing newline. Yes, still. End the file with a blank line and stop thinking about it.
Debugging a cron job that isn't running
When a job doesn't fire, resist the urge to change the expression. Work the chain in order: is it installed, is the daemon alive, did cron try, and did the command survive cron's environment.
Step 1 β confirm it's installed, under the right user. From any shell:
crontab -l # your own jobs
sudo crontab -u www-data -l # a service account's jobs
cat /etc/crontab; ls /etc/cron.d/ # system jobs, which crontab -l never shows
If your line isn't in that output, nothing else matters β wrong user, or the install silently failed.
Step 2 β confirm the daemon is running. The unit name differs by distro, which is its own half-hour of confusion:
systemctl status cron # Debian, Ubuntu
systemctl status crond # RHEL, Rocky, Fedora, Amazon Linux
You want Active: active (running). In a container, there's a good chance no cron daemon was ever started at all β that's the usual reason a crontab that worked on a VM does nothing after a Dockerfile port.
Step 3 β ask cron whether it tried. People skip this one, and it's the step that splits the problem in half:
grep CRON /var/log/syslog # Debian/Ubuntu with rsyslog
journalctl -u cron --since "1 hour ago" # systemd journal; crond on RHEL
-u filters to one unit, --since takes plain English like "today". An attempt looks like this:
Jul 20 03:00:01 web01 CRON[24192]: (deploy) CMD (/usr/local/bin/backup.sh)
If that line is there, cron did its job and your script is the problem. If it isn't, cron never fired β bad expression, wrong user, dead daemon, rejected line. Two more lines worth recognizing:
Jul 20 03:00:01 web01 CRON[24192]: (CRON) info (No MTA installed, discarding output)
Jul 20 03:00:01 web01 CRON[24191]: (CRON) error (grandchild #24192 failed with exit status 127)
The first is cron admitting it destroyed your evidence. The second is the one you're hunting β exit status 127 is the shell's code for "command not found", the PATH problem confessed in numeric form.
Step 4 β reproduce cron's environment at your prompt. env -i runs a command with a completely empty environment, so this is as close to being cron as you get without waiting for the next tick:
env -i PATH=/usr/bin:/bin HOME="$HOME" SHELL=/bin/sh \
/bin/sh -c '/usr/local/bin/backup.sh'
Works in your shell, dies here? You've reproduced the bug in one second instead of one night. To capture the exact environment your daemon hands your jobs, drop in a one-minute probe β * * * * * env > /tmp/cron-env.txt 2>&1 β then read the file and delete the line.
Correlating a cron run against an application log always ends in epoch seconds; the epoch converter translates both directions, and the date calculator handles "how long between these two runs" without off-by-one surprises.
The DST incidents
Cron matches against local wall-clock time, and twice a year the wall clock misbehaves. On spring-forward night, 02:00 jumps to 03:00 β a job scheduled at 2:30 has no 2:30 to run in. On fall-back night, 01:00β02:00 occurs twice.
The mainstream Linux cron (Vixie/ISC) has thought about this: after a spring-forward jump it runs the skipped jobs once shortly after the change, and after fall-back it avoids running the repeated window twice. The bad news is that this is implementation-specific β a slim container image, a NAS, or a cloud scheduler may just skip or double-run β and even on mainline Linux, a "runs shortly after" backup that assumed a quiet 2:30 AM now executes during whatever else is happening at 3:01.
Two policies make the whole problem disappear:
- Run hosts and containers in UTC. UTC has no DST, so wall clock and schedule agree year-round. When a job must align to a local business hour, convert explicitly β the timezone comparison tool shows the UTC hour for any local time, including how it shifts across DST boundaries.
- If local time is unavoidable, schedule outside 01:00β03:00. A 3:30 AM job is exactly as quiet as a 2:30 one, and it exists on all 365 days.
Both rest on the same idea β keep an offset-free clock underneath, convert at the edges β which is the whole argument of our guide to Unix timestamps, timezones, and DST. If your job writes times into a database, read that one before you pick a column type; it covers why a fall-back timestamp maps to two real instants. For a quick conversion mid-incident, the Unix timestamp converter does the back-and-forth in the browser.
Overlap: the failure mode that compounds
Cron schedules starts. It doesn't know or care whether the previous run finished. The incident pattern is always the same: a nightly job's runtime creeps up as data grows, one night it exceeds 24 hours, the next start piles on, both run slower, and within a week several copies are deadlocking each other and the database. The "harmless" cleanup script becomes the outage.
Here's what it looks like caught in the act β a sync job on a */10 schedule, inspected at 02:47:
ps aux | grep '[s]ync-job'
USER PID %CPU %MEM RSS STAT START TIME COMMAND
deploy 14203 62.1 8.4 689104 D 02:00 0:29 /usr/bin/python3 /usr/local/bin/sync-job
deploy 15044 58.7 8.3 683992 D 02:10 0:24 /usr/bin/python3 /usr/local/bin/sync-job
deploy 15991 55.2 8.4 690440 D 02:20 0:21 /usr/bin/python3 /usr/local/bin/sync-job
deploy 16702 51.9 8.3 684180 D 02:30 0:18 /usr/bin/python3 /usr/local/bin/sync-job
deploy 17455 48.3 8.4 689960 D 02:40 0:15 /usr/bin/python3 /usr/local/bin/sync-job
The START column is the whole story: five processes, exactly ten minutes apart, none exited. STAT of D is uninterruptible sleep β they're blocked on I/O, almost certainly on each other's database locks. RSS at ~690 MB apiece makes 3.4 GB nobody planned for. Another four hours and the box either OOM-kills something important or stops answering SSH. (The bracket in grep '[s]ync-job' keeps grep out of its own results.)
The one-line insurance is flock:
*/10 * * * * flock -n /tmp/sync.lock /usr/local/bin/sync-job >> /var/log/sync.log 2>&1
flock takes a lock file and a command: it grabs an exclusive lock, runs the command, and releases the lock when the command exits β including when it crashes, since the kernel drops the lock with the file descriptor. -n ("nonblocking") makes a second instance give up immediately rather than queue, which is right for periodic work: the next tick picks it up. One quirk to know before you test it: a blocked flock -n exits 1 and prints nothing at all β it fails silently by default, which is exactly what you want in a crontab and exactly what confuses everyone the first time.
Two honest limits. flock is per-machine: the same entry on three app servers gives you three concurrent jobs, since they lock three different files on three different filesystems. Fleet-wide exclusion needs a distributed lock (Redis SET NX with a TTL, a database advisory lock) or a scheduler pinned to one node. And flock never tells you it skipped β a job that quietly declines to run for a week looks exactly like a healthy one. Which is the deeper problem.
Silent success is indistinguishable from silent failure
The most dangerous cron job is one that has been "working" for two years β meaning: nobody has checked. Logs answer what happened; they don't alert you that nothing happened, which is the failure mode of a stopped daemon, a typoed crontab, or a disabled GitHub Actions schedule.
The pattern that catches everything is the dead-man's switch: the job's last action is pinging a heartbeat URL, and the monitor alerts when the ping doesn't arrive on schedule. Point it at a URL you watch β even a webhook tester endpoint works for verifying the mechanism before you wire up a real monitor:
0 3 * * * /usr/local/bin/backup.sh && curl -fsS https://your-heartbeat-url/backup-ok >> /var/log/backup.log 2>&1
Note the && β the ping only fires on success, so both "job failed" and "job never ran" produce the same alert. Then test it once by breaking the job on purpose. An alert that has never fired is a hypothesis.
When to graduate from cron
Cron is the right tool for single-machine, time-triggered, fire-and-forget work. Three signals you've outgrown it:
- You need missed runs to catch up β the server was down at 3 AM and the backup should still happen. systemd timers do this with one line,
Persistent=true; cron just skips it. - You need sub-minute precision or guaranteed timing. Cron's floor is one minute and its guarantee is "roughly then."
- You need retries, visibility, or distribution β one job across many workers, dashboards, failure handling. That's a queue or a workflow engine, not a scheduler.
"One line" is cheap without the other lines, so for scale: a timer is two files sharing a basename β backup.service does the work, backup.timer says when β in /etc/systemd/system/, plus a daemon-reload and an enable --now. The schedule half is short:
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
OnCalendar is systemd's syntax, not cron's β *-*-* 03:30:00 is year-month-day then time; Mon..Fri 09:00 does weekdays. The payoff for that boilerplate is systemctl list-timers, which tells you when a job runs next and when it last ran β a question cron has never been able to answer β plus exit codes in systemctl status and output in journalctl -u backup.service, captured with no redirect to remember.
Past that tier, once jobs need retries, branching, external triggers, and a record of what ran and why, you want a workflow engine rather than a scheduler; our n8n setup guide covers self-hosting one.
The graduation path is normal: crontab entry β systemd timer β queue. The mistake is only in skipping the middle upgrades after the reliability requirements have arrived.
The gotcha checklist
Every one of these has caused a real incident. Read it before you deploy a schedule, and again when one misbehaves.
- The two day fields OR together.
0 0 13 * 5is the 13th or Friday β ~56 runs a year. - Steps anchor to the field's range, not to now.
*/40gives :00, :40, then a 20-minute gap. - Cron's PATH is
/usr/bin:/bin, and no shell profile loads. Anything from nvm, pyenv, asdf or Homebrew is invisible. - A bare
%is a newline. Escape every one as\%, especially insidedate +.... - Without
>> logfile 2>&1, output goes to a mailbox nobody reads β or nowhere, if no MTA is installed. /etc/cron.d/and/etc/crontabneed a sixth column (the user), and filenames there can't contain dots.- Jobs are per-user.
crontab -lsays nothing aboutwww-data's jobs. - Cron uses the system timezone, and containers are almost always UTC.
- Don't schedule between 01:00 and 03:00 local. Spring-forward deletes that hour; fall-back repeats it.
- Cron schedules starts, not completions. Wrap it in
flock -nβ and remember the lock is per-machine. crontab -rdeletes everything with no prompt. Back up first.- A job that has "worked for years" is a job nobody has checked. Heartbeat it, then break it on purpose.
- Dialects differ. EventBridge takes six fields and numbers Friday as 6; Quartz starts at seconds; GitHub Actions is UTC-only and best-effort.
The short version
Five fields, smallest to largest; verify expressions with a parser instead of your eyeballs; remember day-of-month and day-of-week OR together; give every production job absolute paths, redirected output, a flock, and a heartbeat; keep servers on UTC; and treat "it's been working for years" as a reason to check, not a reason not to.
Frequently Asked Questions
What is the cron expression for every 5 minutes?
*/5 * * * * β the step syntax */5 in the minute field means 'every value divisible by 5', so the job fires at :00, :05, :10 and so on. Two related expressions people mix up: */5 always aligns to clock minutes divisible by 5 regardless of when you created the job, and 5 * * * * (no asterisk-slash) is something entirely different β it runs once per hour, at five minutes past. The same pattern scales: */15 * * * * for every quarter hour, 0 */2 * * * for every two hours on the hour. One caveat: steps derive from field ranges, so */7 in the minutes field gives you 0,7,14,...,56 and then jumps back to 0 β a 4-minute gap every hour, not a true 'every 7 minutes'.
Why is my cron job not running?
In rough order of likelihood: the environment, the environment, and then everything else. Cron runs with a minimal environment β PATH is typically just /usr/bin:/bin, no shell profile is loaded, and HOME may not be what you expect β so a command that works in your terminal fails in cron because node, aws, or docker isn't on cron's PATH. Use absolute paths or set PATH at the top of the crontab. Next suspects: percent signs (%) in the command, which cron treats as newlines unless escaped as \%; a missing trailing newline at the end of the crontab file, which makes some crons ignore the last line; the cron daemon simply not running; and output going nowhere so the job fails invisibly. Redirect output to a log file first β debugging blind is the real problem.
What happens if I set both day-of-month and day-of-week in a cron expression?
They combine with OR, not AND β the single most counterintuitive rule in cron. The expression 0 0 13 * 5 does not mean 'Friday the 13th'; it runs at midnight on every 13th of the month AND on every Friday, which is roughly 56 runs a year instead of the expected two. This is standard, documented POSIX behavior: when both fields are restricted (neither is *), the job runs when either matches. If you actually need Friday-the-13th semantics, restrict one field in the expression and check the other in the command itself: 0 0 13 * * [ "$(date +\%u)" = 5 ] && /path/to/job. Quartz-based schedulers sidestep the ambiguity by requiring a ? in one of the two fields.
What timezone do cron jobs run in?
The system timezone of the machine or container running the daemon β cron itself has no timezone concept in the standard syntax. That single fact explains a whole family of bugs: containers almost always run UTC, so the '3 AM' cleanup you wrote fires at 3 AM UTC, which is 10 PM in New York; and any server in a DST-observing zone shifts all its job times twice a year relative to UTC. The sanest production policy is to run servers and containers in UTC and translate mentally (or better, in code) when a job must align to a local business hour. Some crons support a TZ= or CRON_TZ= variable in the crontab, Kubernetes CronJobs have a timeZone field, and GitHub Actions is unconditionally UTC β know which one you're writing for.
What happens to my cron job during daylight saving time changes?
It depends on the time you chose, which is why the advice is: don't schedule between 1 AM and 3 AM local time on DST-observing servers. On spring-forward night the clock jumps from 2:00 straight to 3:00, so a 2:30 job's time simply never occurs; on fall-back night 1:00β2:00 happens twice. Vixie/ISC cron (the common Linux one) has explicit handling: jobs in a skipped interval run once shortly after the jump, and jobs in a repeated interval aren't run twice β but not every implementation, container base image, or cloud scheduler replicates that behavior, and few people can quote it under pressure. Scheduling at 3:30 AM, or running everything in UTC, makes the whole question moot.
How do I stop a cron job from overlapping with a previous run that hasn't finished?
Cron will happily start a second copy while the first is still running β it schedules starts, not completions. The standard fix on Linux is flock: */10 * * * * flock -n /tmp/myjob.lock /path/to/job. The -n flag makes the second instance exit immediately instead of queueing, which is usually what you want for a periodic job (the next tick will catch up). Overlap is how 'harmless' jobs take down servers: a nightly batch slows down once, the next run piles on, then the next, and by morning there are nine copies fighting over the database. If a job's runtime can ever approach its interval, locking isn't optional β and past that point you want a real queue, not cron.
Can cron run a job every 30 seconds?
Not natively β standard cron's smallest unit is one minute, and its five fields start at minutes. The traditional workaround is two crontab entries, one plain and one wrapped in a sleep: * * * * * /path/to/job and * * * * * sleep 30 && /path/to/job. It works, but treat it as a signal: once you need sub-minute scheduling, you've likely outgrown cron. systemd timers support OnUnitActiveSec=30s cleanly, Quartz-style schedulers (used by many Java apps and some cloud services) have a six-or-seven-field syntax with a seconds field, and long-running workers with an internal ticker beat both for anything below ~15 seconds. GitHub Actions goes the other direction β its shortest schedule interval is 5 minutes, and even that isn't guaranteed on time.
Should I use cron or systemd timers?
For a quick recurring command on a box you control, cron wins on ubiquity and one-line simplicity. systemd timers earn their extra boilerplate (a .timer unit plus a .service unit) when you need the features cron lacks: Persistent=true runs a missed job after the machine was off β cron just skips it; RandomizedDelaySec spreads load across a fleet so a thousand servers don't hit your API at the same second; logs land in journald automatically instead of wherever you remembered to redirect them; and dependencies let a job wait for the network or a mount. A reasonable rule: crontab for developer conveniences, timers for anything a team relies on, and a proper job queue once you need retries and observability.