AI-Generated Cron Jobs: 13 Mistakes to Catch in Code Review
Contents
To review an AI-generated cron job, check what the model could not see from your prompt: which scheduler parses the expression, which time zone it runs in, what environment the command starts with, and what happens when a run fails, hangs, overlaps, or never starts. These mistakes are all valid syntax, which is why they survive a quick read. The thirteen patterns below apply to any pull request that adds or changes a crontab line, a Kubernetes CronJob, a GitHub Actions schedule, or a job in Hangfire, Quartz, Spring, Celery, APScheduler, or node-cron. Each shows the plausible version, the rule it breaks, and the fix.
Rules are cited from cronie 1.7, Debian's cron, Kubernetes 1.27 and later, and vendor documentation checked October 24, 2026. For job hygiene in general, see preventing errors in scheduled automation; this guide is about reading the diff.
1. The Expression Is Written for a Different Scheduler#
Schedulers disagree on how many fields an expression has. The costly case is a six-field Spring, Quartz, or node-cron expression pasted into a crontab:
# Before: "02:00 daily" in Spring's seconds-first format
0 0 2 * * * /opt/app/backup.sh
# After: five fields
0 2 * * * /opt/app/backup.shA crontab line has five time fields, and the rest of the line is the command (crontab(5)). The first line runs at 00:00 on the 2nd of each month, and its sixth * becomes the first word of the command. The shell expands it to the file names in the user's home directory, where cronie starts each job, and tries to run the first one.
The reverse mistake fails loudly: Spring rejects anything but six fields, and Quartz requires six or seven. AWS EventBridge wants cron(0 2 * * ? *): six fields, the year last, and ? in one day field (EventBridge Scheduler). Quartz and AWS also number Sunday as 1, so 1 in the day-of-week field is Sunday there and Monday in a crontab.
Shortcuts don't carry over either. Kubernetes documents @yearly through @hourly and no @reboot (schedule syntax), and GitHub Actions supports none of them. Where @reboot works, Debian's crontab(5) notes it fires when the cron daemon starts, possibly before other services are up. Cron expression syntax compares the dialects.
2. The Schedule Assumes the Wrong Time Zone#
An expression carries no time zone. A comment saying "9 a.m." records intent, not behavior:
# Before: meant as 09:00 in New York, runs at 09:00 UTC
on:
schedule:
- cron: '0 9 * * 1-5'
---
# After
on:
schedule:
- cron: '0 9 * * 1-5'
timezone: 'America/New_York'Check the zone of the process that evaluates the schedule, not the author's:
| Scheduler | Zone when none is set | Override |
|---|---|---|
| cronie | The cron daemon's local zone | CRON_TZ= in the crontab (crontab(5)) |
| Kubernetes CronJob | kube-controller-manager's zone | .spec.timeZone, stable since 1.27 (time zones) |
| GitHub Actions | UTC | timezone on the schedule entry (schedule) |
Spring @Scheduled |
The scheduler's zone | zone attribute (javadoc) |
| Hangfire 1.8 | UTC | RecurringJobOptions.TimeZone (source) |
| Celery beat | UTC | timezone setting (configuration) |
| APScheduler 3.x | The local zone | timezone argument (scheduler) |
| node-cron 4 | The system zone | timezone option (options) |
Kubernetes rejects CRON_TZ= or TZ= inside .spec.schedule; use the field. If a job must follow local business hours, set the zone explicitly; otherwise write the schedule in UTC and say so in a comment.
3. Both Day Fields Are Restricted#
# Before: meant as "the first Monday of the month"
0 9 1-7 * 1 /opt/app/monthly-review.sh
# After: run on the 1st through 7th; the script exits early unless it is Monday
0 9 1-7 * * /opt/app/monthly-review.shIn cronie and other Vixie-derived crons, when both day-of-month and day-of-week are restricted, the job runs when either matches (crontab(5)). The first line runs on each of the first seven days and on every Monday. Put the weekday test at the top of the script: [ "$(date +%u)" -eq 1 ] || exit 0. Quartz and AWS sidestep the ambiguity by requiring ? in one of the two fields.
4. A Percent Sign in the Crontab Command#
# Before
0 2 * * * tar czf /backups/app-$(date +%F).tar.gz /srv/app
# After
0 2 * * * tar czf /backups/app-$(date +\%F).tar.gz /srv/appCron ends the command at the first unescaped %, turns any later ones into newlines, and sends that text to the command's standard input (crontab(5)). The shell receives tar czf /backups/app-$(date + and fails with a syntax error every night. Escape each % as \%, or move the command into a script, where % needs no escaping.
5. The Command Depends on Your Shell Session#
# Before: works in your terminal
0 4 * * * cd app && source .venv/bin/activate && python manage.py clearsessions
# After
0 4 * * * cd /srv/app && /srv/app/.venv/bin/python manage.py clearsessionsCron runs the command with /bin/sh and sets SHELL, LOGNAME, and HOME itself (crontab(5)); cronie 1.7 falls back to a PATH of /usr/bin:/bin:/usr/sbin:/sbin (source). A non-interactive sh reads no startup files (Bash manual), so whatever your .bashrc sets up, including nvm, pyenv, and aliases, is absent. On Debian, /bin/sh is dash, which has . but no source. The relative cd app works only because cron starts in the home directory.
A virtual environment doesn't need activating when you call its interpreter by full path, which is the fix. Also look for PATH=$HOME/bin:$PATH in a crontab: Debian's crontab(5) says values are not expanded, so PATH becomes that literal text. Cron job not running covers the other differences.
6. Nothing Stops Two Runs From Overlapping#
# Before
*/10 * * * * /opt/app/sync.sh
# After: skip this run while the previous one holds the lock
*/10 * * * * flock -n /opt/app/sync.lock /opt/app/sync.shCron starts each run on schedule whether or not the last one finished. Check the flag, too: without -n, flock(1) waits for the lock, so runs queue behind a slow one instead of being skipped. Kubernetes CronJobs default to concurrencyPolicy: Allow; set Forbid (concurrency policy). In GitHub Actions, a concurrency group keeps one run at a time. In-app schedulers have their own switch, such as node-cron 4's noOverlap, which defaults to false.
7. Nothing Limits How Long a Run Takes#
# Kubernetes: the deadline goes on the Job spec inside jobTemplate
spec:
jobTemplate:
spec:
activeDeadlineSeconds: 1800
---
# GitHub Actions: without this, a job can run for the default 360 minutes
jobs:
export:
runs-on: ubuntu-latest
timeout-minutes: 30A hung run never fails, so nothing reports it, and with overlap protection it also blocks every later run. When a Job reaches activeDeadlineSeconds, Kubernetes terminates its pods and marks it failed with reason DeadlineExceeded; the pod template has a field with the same name, and the docs warn to set it at the right level. GitHub cancels a job at timeout-minutes, 360 by default. In shell, timeout 30m /opt/app/sync.sh exits 124 when time runs out (timeout(1)). In the code, look for network and database calls without a timeout: Python's requests can hang indefinitely unless you pass one.
8. Failures Are Caught and Discarded#
# Before: logs the error, then exits 0
def main():
try:
sync_invoices()
except Exception as exc:
log.error("invoice sync failed: %s", exc)
# After: log it and re-raise, so the process exits non-zero
def main():
try:
sync_invoices()
except Exception:
log.exception("invoice sync failed")
raiseThe scheduler, the CI status, and any monitor learn about a failure only from the exit code or an uncaught exception. The same mistake takes other forms: except: pass, a job wrapper that catches and logs, || true after the job's command, and continue-on-error: true on a GitHub Actions step, which lets the job pass when the step fails. In a Kubernetes CronJob, a container command ending in || true exits 0, so the Job completes and backoffLimit never retries it. || true belongs on the monitoring call, never on the job.
9. A Pipe Hides the Exit Code#
# Before: GitHub's default shell is bash -e {0}, without pipefail
- run: ./scripts/export.sh | tee export.log
---
# After: shell: bash adds -o pipefail
- shell: bash
run: ./scripts/export.sh | tee export.logA pipeline's exit status is the last command's unless pipefail is set (Bash manual), so a failed export piped into tee reports tee's success. GitHub Actions runs an unspecified Linux shell as bash -e {0} and adds -o pipefail only when shell: bash is explicit (workflow syntax). In a crontab, job | tee -a log has the same problem. Redirect instead, with >> /var/log/app/export.log 2>&1, or pipe inside a bash script that starts with set -o pipefail.
10. The Monitoring Call Lies or Breaks the Job#
#!/bin/bash
# Before
set -e
curl -fsS --user "$CRONRADAR_API_KEY:" https://cron.life/api/v1/ping/backup
/opt/backup.shThis check-in has three problems. It reports success before the backup runs, so a failed backup still looks healthy. It has no --max-time, and curl sets no overall time limit by default (CURLOPT_TIMEOUT), so a stalled connection stalls the job. And under set -e, a failed check-in, whether from a DNS error, a server error, or an outage, exits the script before the backup starts. In application code, the same mistake is a monitoring request whose exception escapes and turns a successful run into a failed one.
The fix reports after the work, caps each report at five seconds, ignores report errors, and exits with the job's own code:
#!/bin/sh
url="https://cron.life/api/v1/ping/backup"
run_id="backup-$(date +%s)-$$"
report() {
event=$1
shift
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--request POST --get --user "$CRONRADAR_API_KEY:" \
--data-urlencode "schedule=30 2 * * *" \
--data-urlencode "gracePeriod=900" \
--data-urlencode "run_id=$run_id" \
"$@" "$url/$event" || true
}
report start
/opt/backup.sh
rc=$?
if [ "$rc" -eq 0 ]; then
report complete
else
report fail --data-urlencode "message=backup.sh exited with code $rc"
fi
exit "$rc"11. An In-Process Scheduler Runs on Every Replica#
// Before: server.js, started by every replica of the web service
import cron from 'node-cron'
cron.schedule('0 3 * * *', sendDailyDigest)
app.listen(3000)// After: only a process started with RUN_SCHEDULER=true schedules jobs
if (process.env.RUN_SCHEDULER === 'true') {
cron.schedule('0 3 * * *', sendDailyDigest)
}
app.listen(3000)A scheduler started inside a web process runs in every copy of that process, so three replicas send three digests. node-cron's docs describe exactly this and offer a distributed option. Celery says to run only one beat scheduler, APScheduler's FAQ says sharing a job store between processes leads to duplicate execution, and a .NET BackgroundService loop runs in every instance. Schedulers that coordinate through shared storage avoid this: Hangfire servers use distributed locks, and clustered Quartz fires each trigger on one node. Otherwise, move the schedule into a Kubernetes CronJob or one worker, or gate it as above and set the variable on a single one-replica deployment.
12. A Secret Is in the Schedule Line or Committed YAML#
# Before
0 * * * * curl -fsS "https://hooks.example.com/sync?token=9f2c41d7e8b3a6c5"
# After: the header file is mode 600 and owned by the job's user
0 * * * * curl -fsS --max-time 30 -H @/etc/app/sync-auth.header https://hooks.example.com/synccronie writes a syslog message for each command it runs (crontab(5) documents a root-only prefix that turns it off), and any user can read another process's command line unless /proc is mounted with hidepid (proc(5)). Servers and proxies log URLs too. With -H @file, curl reads headers from a file, so the token appears in none of those places. GitHub's secrets guide gives the same warning: other users may see command lines with ps.
In committed YAML, look for a credential under value: in a CronJob's env and replace it with valueFrom.secretKeyRef. In a workflow, use ${{ secrets.SYNC_TOKEN }}.
13. The Run Lands in the DST Gap or on the Busiest Minute#
A local-time job in the hour your zone skips or repeats at a daylight saving change can run late, twice, or not at all. Quartz's docs flag the hour before and after 02:00 in US zones. Schedulers handle it differently, and cronie's own manuals disagree:
| Scheduler | Job in the skipped hour | Job in the repeated hour |
|---|---|---|
| cronie, per crontab(5) | Never runs | Runs twice |
| cronie, per cron(8) | Runs right after the change | Runs once |
| Debian cron, per cron(8) | Runs soon after the change | Runs once |
GitHub Actions with timezone |
Moves to the next valid time | Not documented |
| AWS EventBridge Scheduler | Skipped | Runs once |
Both cron(8) pages apply that handling only to jobs at a fixed time; jobs that run hourly or more often are scheduled normally. Keep important local-time jobs out of the transition hours, or schedule them in UTC.
Also avoid the minute everyone picks. GitHub warns that scheduled workflows can be delayed under high load, including at the start of every hour, and that some queued jobs may be dropped. On your own servers, every job at 0 0 * * * competes for the same database and CPU. Pick an odd minute, such as 17 3 * * *.
Where External Monitoring Fits#
Every check above reads code. None proves the deployed job runs: a deploy can replace the crontab, a CronJob can be left suspended, and GitHub disables scheduled workflows in a public repository after 60 days without activity. A check-in that carries the job's schedule catches the run that never happens, because the alert fires on a missing signal rather than an error. That is a dead man's switch.
With CronRadar, the wrapper in item 10 is the whole setup for a crontab job. The first check-in creates the monitor from its schedule, a five-field expression read in UTC; a fail alerts immediately; and a start with no finish is flagged as stuck once the next expected run plus its grace period passes. Keep the timeout from item 7, which turns a hang into a fail. There is no CronRadar SDK or auto-discovery for crontab, Kubernetes CronJobs, or GitHub Actions, so each job reports with its own HTTP check-in. See setting up monitoring with an AI agent to have the assistant add it, and Kubernetes CronJob monitoring for cluster-side signals.
Review Checklist#
| Check | Look for | Fix |
|---|---|---|
| Dialect | Six fields in a crontab; five in Spring or Quartz; @reboot |
The target scheduler's format |
| Time zone | A local time in a comment, no zone set | timeZone, timezone, zone, or UTC |
| Day fields | Day-of-month and day-of-week both restricted | Restrict one; test the other in the script |
% |
An unescaped % in a crontab command |
\% or a script |
| Environment | source, relative paths, nvm, pyenv, $PATH in a crontab |
Absolute interpreter and script paths |
| Overlap | No lock, flock without -n, no concurrencyPolicy |
flock -n, Forbid, concurrency |
| Time limit | No deadline on the run or its network calls | timeout, activeDeadlineSeconds, timeout-minutes |
| Failures | except: pass, catch-and-log, || true, continue-on-error |
Re-raise; exit non-zero |
| Pipes | job | tee without pipefail |
shell: bash, set -o pipefail, or >> |
| Monitoring | Check-in before the work, no --max-time, set -e |
Report after; 5-second timeout; ignore report errors |
| Replicas | A scheduler started in a scaled web process | One scheduler, or one that coordinates |
| Secrets | Tokens in crontab lines, URLs, or value: |
Files, secret stores, secretKeyRef |
| Timing | Local times in the DST transition hours; minute 0 | UTC; an odd minute |
Rules to Paste Into Your Agent's Instructions#
Put these in the rules file your coding agent reads, such as AGENTS.md or CLAUDE.md. They make the first draft closer to right; they don't replace the review.
## Scheduled jobs
- Name the scheduler that will parse each schedule and use its format: five fields for crontab, Kubernetes, and GitHub Actions; six, seconds first, for Spring; six or seven for Quartz; cron(...) with a year and ? for AWS EventBridge.
- Use @reboot or other @ shortcuts only where the target scheduler documents them.
- Set the time zone explicitly (timeZone, timezone, zone, CRON_TZ), or write the schedule in UTC and say so in a comment.
- Never restrict both day-of-month and day-of-week in a crontab. Restrict one and check the other in the script.
- Escape every % in a crontab command as \%, or move the command into a script.
- Use absolute paths for interpreters, scripts, and files. Do not rely on PATH, aliases, nvm, pyenv, an activated virtualenv, or the working directory.
- Prevent overlapping runs: flock -n for cron, concurrencyPolicy: Forbid for Kubernetes, a concurrency group for GitHub Actions, the scheduler's own setting elsewhere.
- Give every run a time limit and every network or database call a timeout.
- Exit non-zero when the job fails. Never hide a failure with a catch that only logs, || true, or continue-on-error.
- Set pipefail wherever the job's output is piped.
- Report success only after the work finishes. Give monitoring calls a 5-second timeout, ignore their errors, and keep the job's exit code.
- Never start an in-process scheduler in a multi-replica service unless exactly one instance runs each job.
- Never put credentials in a crontab line, a URL, or committed YAML. Read them from a protected file, the environment, or a secret store.
- Keep local-time jobs out of the daylight saving transition hours, and avoid minute 0.
- In the pull request description, list the scheduler, the expression, its time zone, and the next three run times.Frequently Asked Questions#
Can ChatGPT or Claude write a correct cron expression?#
A model can produce a valid expression, but valid isn't correct. An expression is only correct relative to the scheduler that parses it and the time zone it runs in, and a prompt like "every weekday at 9" names neither. Tell the assistant which scheduler and zone you use, then check the result against that scheduler's documentation. cronie's crontab -T tests a crontab's syntax before you install it.
Why does my AI-written cron job work in the terminal but not in cron?#
Cron doesn't run your terminal session. It starts the command with /bin/sh, a short PATH, and your home directory as the working directory, and reads none of your shell startup files. Call interpreters and scripts by absolute path.
How do I review a cron job in a pull request?#
Establish the scheduler and its time zone first, because every other check depends on them. Then work down the checklist above. Ask the author, human or agent, to list the next three run times in the description so you can compare them with the intent.
What rules should I give my coding agent for scheduled jobs?#
Paste the block above into AGENTS.md, CLAUDE.md, or whichever rules file your agent reads; it states the thirteen checks as instructions. Keep reviewing the diff, since an instruction file guides the draft but doesn't verify it.
How do I know an AI-written scheduled job actually runs in production?#
Watch it from outside the system that runs it. A check-in that carries the schedule lets a monitor alert when an expected run doesn't arrive, which catches a removed crontab or a dead scheduler that no review can. With CronRadar, the wrapper in item 10 creates the monitor on its first run and alerts on missed, failed, and stuck runs.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.