Lint cron schedules: impossible dates, typos, DOM/DOW OR gotcha, DST gaps, plain-English descriptions (MIT, free; pipx install) https://errant.solutions/tools/cron-sanity-checker.html
  • Python 87.9%
  • Shell 12.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Hermes Agent c29a97e650
All checks were successful
tests / test (3.14) (push) Successful in 1m3s
tests / test (3.8) (push) Successful in 50s
ci: mutation-incremental.py shared-baseline version (copy of ctg-infrastructure 87ba7a5)
2026-10-21 20:25:50 -07:00
.forgejo/workflows mutation/chaos CI on every rw-man host again (owner decision 2026-10-18: colo backlog too big, GuardDuty suppression kept): EC2 guards are opt-in (refuse only with CI_COLO_ONLY=1, code kept), colo jobs move to the shared labels mutation-x86_64 / mutation-any (rw-man-01..05, 6g slots) 2026-10-18 13:45:30 -07:00
ci ci: mutation-incremental.py shared-baseline version (copy of ctg-infrastructure 87ba7a5) 2026-10-21 20:25:50 -07:00
tests Mutation testing: copy all of ci/ into mutmut's sandbox (also_copy of ci/check_claims.py failed: no mutants/ci dir), keep real sleeps in fixture-server threads (--timeout test), per-repo mutant parallelism (ci/mutation-children) 2026-10-16 23:56:29 -07:00
.gitignore nightly mutation: incremental (only functions changed since the last green run) except a weekly full run on the repo's own weekday; replace-queued concurrency; staggered cron 2026-10-18 12:22:47 -07:00
CHANGELOG.md Release 1.2.1: the test-depth fixes since 1.2.0 (see CHANGELOG) 2026-10-17 01:49:59 -07:00
claims.json Test depth: accuracy suite (false negatives/positives vs independent references), nightly mutation testing + full Python matrix + install-channel checks, claims ledger 2026-10-16 21:28:37 -07:00
cron_sanity_checker.py Release 1.2.1: the test-depth fixes since 1.2.0 (see CHANGELOG) 2026-10-17 01:49:59 -07:00
LICENSE MIT LICENSE, --json, plain-English schedule descriptions, CRON_TZ/--tz DST gap/overlap warnings, systemd OnCalendar input; pyproject (pipx), --version, tag releases with checksums 2026-10-16 19:07:18 -07:00
pyproject.toml Mutation testing: copy all of ci/ into mutmut's sandbox (also_copy of ci/check_claims.py failed: no mutants/ci dir), keep real sleeps in fixture-server threads (--timeout test), per-repo mutant parallelism (ci/mutation-children) 2026-10-16 23:56:29 -07:00
README.md Release 1.2.1: the test-depth fixes since 1.2.0 (see CHANGELOG) 2026-10-17 01:49:59 -07:00

cron-sanity-checker

Catches broken/misleading cron schedules before they bite you in production. Zero dependencies (Python stdlib only), no network calls, works entirely offline.

A plain crontab -e syntax check won't catch any of these — this tool does:

  • Impossible dates — a job that can never fire because its day-of-month never occurs in its month(s), e.g. 0 0 31 4 * or 0 0 30 2 * (error), plus dead combinations inside an explicit month list such as 31 with 1,4 (warning) and Feb 29 schedules that only fire in leap years (warning). 0 0 31 * * (every month that has a 31st) is a normal schedule and is not flagged.
  • Out-of-range field values — minute 60, hour 24, month 13, day-of-week 8, etc.
  • Backwards ranges — 10-5 in a field where wraparound isn't valid.
  • Step-of-zero typos — */0, which some cron implementations silently treat as * (every unit), silently changing your intended schedule.
  • The classic day-of-month + day-of-week gotcha — POSIX cron OR's these two fields together when both are restricted (fires if either matches), which surprises almost everyone expecting AND semantics.
  • Overlapping jobs — two or more entries in the same crontab file that can fire in the same minute, a common cause of resource contention or silent races (flagged as a warning, not an error). Jobs in different time zones aren't compared.
  • DST traps — with a time zone (--tz, a CRON_TZ=/TZ= line in the file, or $TZ) it warns about run times that don't exist on the spring-forward day (skipped or moved, depending on the daemon) or happen twice on the fall-back day. Needs Python 3.9+ (zoneinfo).
  • Plain-English description of every schedule, e.g. 30 2 * * 1-5 -> "At 02:30 on Monday through Friday".
  • systemd timers — --oncalendar 'Mon..Fri *-*-* 02:30' translates an OnCalendar= expression to cron and runs the same checks (seconds other than 0, years and ~ can't be expressed in cron and are reported as errors).

Install

pipx install git+https://git.canutethegreat.com/CanuteTheGreat/cron-sanity-checker.git        # latest release branch; puts `cron-sanity-checker` on PATH
pipx install git+https://git.canutethegreat.com/CanuteTheGreat/cron-sanity-checker.git@v1.2.1  # a pinned release
brew install canutethegreat/errant-tools/cron-sanity-checker   # after: brew tap canutethegreat/errant-tools https://git.canutethegreat.com/CanuteTheGreat/homebrew-errant-tools
scoop install errant-tools/cron-sanity-checker                 # after: scoop bucket add errant-tools https://git.canutethegreat.com/CanuteTheGreat/scoop-errant-tools

Or download the single file (stdlib-only Python 3.8+) and run it directly: curl -fsSLO https://errant.solutions/downloads/cron_sanity_checker.py && python3 cron_sanity_checker.py --help. Every release has a changelog, the wheel, the sdist and a SHA256SUMS file. Not on PyPI yet, so plain pipx install cron-sanity-checker doesn't work until it's published there.

Usage

cron-sanity-checker "*/5 * * * *"
cron-sanity-checker --file /etc/crontab
cron-sanity-checker --file mycrontab --show-next 5 --json
cron-sanity-checker "30 2 * * *" --tz America/New_York        # DST gap warning
cron-sanity-checker --oncalendar "Mon..Fri *-*-* 02:30"
cron-sanity-checker "@weekly backup.sh" --show-next 3 --from "2026-01-01 00:00"

--show-next N prints the next N run times (real cron semantics: day-of-month and day-of-week OR'd when both are set, 5/15 = 5-59/15); --from computes them from a given local time instead of now. Schedule macros (@yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly) are expanded and checked; @reboot and VAR=value lines are skipped (CRON_TZ=/TZ= set the zone for the lines after them). --json prints {"version", "exit_code", "jobs": [{"line", "schedule", "command", "description", "time_zone", "ok", "next"}], "errors", "warnings"}. Python 3.8+.

Exit codes: 0 = no errors (warnings still possible), 1 = at least one error found (or no job lines), 2 = usage error (no input, unreadable --file, bad --from/--tz).

Example

$ cron-sanity-checker "0 0 31 4 * some-job.sh"
[ERROR] line 1: 0 0 31 4 *  ->  some-job.sh
    At 00:00 on the 31st of the month in April

ERRORS:
  ✗ line 1: day-of-month [31] can never occur in Apr — this job will never fire

License

MIT — see LICENSE. Free to use; pay-what-you-want if it saved you a debugging session: see the Stripe link on https://errant.solutions

Tests

tests/ runs every option end to end in Forgejo CI on Python 3.8 and current Python, cross-checking next-run times against croniter, then installs the package with pip and checks --version/--help; sh ci/run-tests.sh 3.13 reproduces it locally (needs docker).

— Errant Solutions