The cron in your Python scheduler is a library, not crond
If your scheduling lives in Airflow, a Django management command, or a homegrown loop that wakes up and asks "should this run now?", the thing answering is almost never the cron daemon. It is croniter, a small Python library that turns a cron string into the next datetime it should fire. You wrote 0 2 * * 0 expecting what crond would do, and you get croniter's reading of it instead. The two do not always agree.
That gap matters because croniter had an eventful year. It nearly got pulled off PyPI, changed hands, and shipped bugfix releases that moved when some expressions fire. Nothing shouts when a scheduler fires an hour off or skips a day, so you may never notice.
Where croniter actually runs
Apache Airflow uses croniter to evaluate cron-based schedules, and plenty of internal schedulers and Django jobs import it to answer "when is the next run?". At roughly 46 million downloads a month on PyPI, that is a lot of schedules resting on one parser, none of them running your system crond. They run croniter's reading of the string, in whatever timezone the Python process happens to sit in.
It parses a different cron than crond does
Vixie cron, the crond on most Linux boxes, accepts five fields and a fixed set of extensions. croniter accepts more, and that is the first place expectations break.
Six fields, sometimes seven
croniter reads an optional sixth field for seconds and an optional seventh for years. */30 * * * * * is a valid every-30-seconds schedule to croniter and a syntax error to crond.
L, W, and the Jenkins-style hash
croniter supports L for the last day of the month, W for the nearest weekday to a date, and sat#1 for the first Saturday. It also takes H and R for hashed and random values, the Jenkins trick for spreading load off round numbers. None of these mean anything to Vixie cron, so an Airflow expression will not copy into a crontab unchanged.
Day-of-month and day-of-week default to OR
When both the day-of-month and day-of-week fields are restricted, POSIX cron runs the job when either one matches. croniter follows that OR behaviour by default through its day_or option. So 0 0 13 * 5 does not mean "Friday the 13th". It means the 13th, and also every Friday. Flip day_or and you get AND semantics.
A patch release can move when a job fires
Fix a bug in how croniter maps a string to datetimes and the datetimes change, which makes its patch notes something you have to read.
The leap-year skip
Version 6.2.1, on 15 March 2026, fixed get_prev skipping 29 February on leap years for day-of-month expressions. Pinned below that, a backfill walking backwards through time quietly stepped over a real day.
The Sunday-wrap fix
Version 6.2.4, on 10 July 2026, corrected the day-of-week low bound for stepped ranges so Sunday wraps properly. Cron numbers Sunday as both 0 and 7, and stepped ranges over that field are where off-by-one bugs live. A job firing on the wrong days before the fix fires on a different set after.
Quadratic expansion and stricter validation
Version 6.2.3, on 2 July 2026, fixed quadratic expansion of long comma-separated range lists and started rejecting zero steps. Version 6.2.0 added a strict flag for cross-field validation that can reject on deploy a string your scheduler accepted for years, which you want to hit in CI rather than at 2 a.m.
The dependency almost disappeared
In December 2024 the maintainer opened an issue titled "Hopefully not the end", planning to stop development and possibly unpublish the package in 2025, citing the compliance burden of the EU Cyber Resilience Act. It was rescued rather than removed: croniter now lives under pallets-eco, the community organisation behind Flask, and keeps shipping releases. Forget the specific library for a moment. The parser that decides when your jobs run is a dependency with its own release schedule, and a change in it is a change to your schedule.
Pin it, test it, and watch the actual run
Pin croniter to an exact version so an unattended upgrade cannot shift your fire times without a code review. When you upgrade, assert the fire times you expect instead of trusting the diff:
from datetime import datetime
from croniter import croniter
schedule = croniter("0 0 13 * 5", datetime(2026, 1, 1))
# The 13th AND every Friday - OR semantics, not "Friday the 13th"
print(schedule.get_next(datetime))
No local test proves the job actually ran in production, so watch the run itself. A dead man's switch that expects a check-in on the schedule you think you configured fires the moment croniter and your intent disagree, whether the cause is a dialect quirk, an upgrade, or a wrong timezone.
#!/bin/bash
set -euo pipefail
python -m myapp.nightly_rollup
curl -fsS --retry 3 https://cronguard.app/api/ping/your-monitor-id
The parser can be wrong about when. The check-in tells you whether.
Frequently asked questions about croniter and Python cron parsing
Is croniter the same as the cron daemon on my server? No. croniter is a Python library that maps a cron string to datetimes inside your process, while crond is a separate system service. They share the five-field syntax but diverge on extensions and edge cases, so the same expression can resolve differently in each.
Why would upgrading croniter change when my jobs run? Fixing a bug in how an expression maps to run times changes the set of times it produces. The 6.2.1 leap-year fix and the 6.2.4 Sunday-wrap fix both altered which dates matching expressions resolve to, so a patch upgrade can move real fire times.
Does croniter treat day-of-month and day-of-week as AND or OR? By default it uses OR, matching POSIX cron: when both fields are restricted the job runs when either one matches. So an expression meant as "Friday the 13th" runs on the 13th and on every Friday. Switch to AND with the day_or option.
Can I copy a croniter expression straight into my crontab? Not always. croniter accepts a seconds field, a year field, and extensions like L, W, the nth-weekday hash, and Jenkins-style H and R that Vixie cron does not understand. A plain five-field expression ports fine; anything using those extras will not.
Was croniter really going to be removed from PyPI? The maintainer opened an issue in December 2024 planning to end development and possibly unpublish it, citing the EU Cyber Resilience Act. pallets-eco adopted it instead and keeps shipping releases, but the episode shows the parser your schedule depends on has its own maintenance lifecycle.
Further reading
- Crontab Syntax Explained: From Basics to Advanced Schedules
- Time Zone Changes Are Moving When Your Cron Jobs Run
- Dead Man's Switch Monitoring: The Only Reliable Way to Watch Cron Jobs
Conclusion: The cron string in your Python scheduler is interpreted by croniter, not by the daemon you tested against, and that parser has a dialect and a bug history of its own. Pin it to an exact version. Assert the fire times you expect when you upgrade. And monitor the actual run, so a silent disagreement between what you wrote and what croniter does becomes an alert instead of a missing job.
Sources: croniter on PyPI, croniter changelog, and the "Hopefully not the end" issue.