cron vs Airflow vs Prefect for municipal feed schedules
You have eleven county feeds, a watermark table, and a decision to make about what runs the thing. Feature comparisons are not much help here, because all three candidates can obviously “run a task on a schedule” — and the differences that actually cost you time only appear once the same logic is written in each. So that is what this page does: one ingest, guarded by a watermark, expressed as a cron script, an Airflow DAG, and a Prefect flow, with attention on the three places they genuinely diverge. It is the practical companion to scheduling & orchestration for municipal feed runs, which covers the watermark design itself.
Diagnosis: what you are actually choosing between jump to heading
Before writing any of the three, be clear about which problem you have, because the answer is different for each.
If the pain is “I need this to run at 2am and tell me when it breaks,” that is a cron problem and it stays a cron problem for a surprisingly long time. Eleven sources with independent watermarks is not a distributed systems challenge; it is a loop.
If the pain is “somebody has to re-ingest last Tuesday for four counties,” you have a backfill problem, and backfill is where a real scheduler starts paying. The question “run this again for that logical date” is the one Airflow was built around, and hand-rolling it means reimplementing date-parameterised runs, partial re-execution, and a record of which logical dates succeeded.
If the pain is “I cannot test any of this,” the framework is not the cause. A task whose body calls datetime.now() and reads a global config is untestable under all three. Fix that first — the same task body should run under any of them — or you will migrate the problem rather than solve it.
The three implementations below share one task body deliberately, to make that last point concrete.
# ingest.py — no framework imports. This is the part that must not care.
from dataclasses import dataclass
@dataclass(frozen=True)
class Result:
source_id: str
verdict: str # "run" | "skip" | "restated"
records: int
def ingest_source(source_id: str, store, fetch, logical_date) -> Result:
"""Idempotent for a given (source_id, logical_date). Takes its clock and its
IO as arguments, so it can be exercised without a scheduler, a network, or a
real database."""
wm = store.load(source_id)
probe = fetch.probe(source_id, as_of=logical_date)
if wm and not probe.changed_since(wm):
return Result(source_id, "skip", 0)
payload = fetch.get(source_id, as_of=logical_date)
if wm and payload.published_at and wm.published_at \
and payload.published_at < wm.published_at:
return Result(source_id, "restated", 0)
n = store.upsert(source_id, payload, logical_date) # idempotent upsert
store.advance_watermark(source_id, payload) # only after the write
return Result(source_id, "run", n)
Step-by-step implementation jump to heading
1. cron plus a lease table jump to heading
#!/usr/bin/env python3
"""run_ingest.py — invoked by: 15 2 * * * /opt/venv/bin/python run_ingest.py"""
import logging
import sys
from datetime import date
from ingest import ingest_source
from infra import Store, Fetcher, lease
SOURCES = ["larimer", "boulder", "weld", "adams"]
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("ingest")
def main(logical_date: date) -> int:
store, fetch = Store(), Fetcher()
failures = []
for source_id in SOURCES:
# A lease PER SOURCE, not a global lock: a global one would recreate the
# cross-jurisdiction coupling the whole design exists to avoid.
with lease(f"ingest:{source_id}", ttl_seconds=3600) as held:
if not held:
log.warning("%s: already leased by another run, skipping", source_id)
continue
try:
r = ingest_source(source_id, store, fetch, logical_date)
log.info("%s: %s (%d records)", source_id, r.verdict, r.records)
except Exception:
log.exception("%s: failed", source_id)
failures.append(source_id)
if failures:
log.error("run finished with %d failure(s): %s", len(failures), failures)
return 1
return 0
if __name__ == "__main__":
given = sys.argv[1] if len(sys.argv) > 1 else None
main(date.fromisoformat(given) if given else date.today())
That is the whole thing. run_ingest.py 2026-08-04 is your backfill, the for loop is your parallelism story until it is not, and the exit code is your alerting integration. What you do not get: a record of which logical dates have run, a UI, retries with backoff between sources, or any way to run the four sources concurrently without adding threads yourself.
2. Airflow, one task per source jump to heading
from datetime import timedelta
import pendulum
from airflow.decorators import dag, task
from ingest import ingest_source
from infra import Store, Fetcher
SOURCES = ["larimer", "boulder", "weld", "adams"]
@dag(
schedule="15 2 * * *",
start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
catchup=False, # municipal feeds are not replayable by date
max_active_runs=1, # the lease, expressed declaratively
default_args={
"retries": 0, # retries live IN the task, not here
"execution_timeout": timedelta(minutes=45),
},
tags=["municipal", "ingest"],
)
def municipal_ingest():
@task(map_index_template="")
def ingest(source_id: str, **context):
# The logical date is the reason to use Airflow at all: this is what makes
# `airflow tasks run ... 2026-08-04` a real backfill rather than a re-run.
logical_date = context["logical_date"].date()
r = ingest_source(source_id, Store(), Fetcher(), logical_date)
return {"source": r.source_id, "verdict": r.verdict, "records": r.records}
@task
def publication_gate(results: list[dict]):
held = [r["source"] for r in results if r["verdict"] == "restated"]
if held:
raise ValueError(f"held for review: {held}")
return sum(r["records"] for r in results)
publication_gate(ingest.expand(source_id=SOURCES))
municipal_ingest()
Two choices there are worth arguing about. catchup=False is right for municipal feeds because a portal only serves current content — replaying last month’s logical dates fetches today’s data eleven times and stamps it with historical dates, which is worse than not backfilling at all. Backfill for these sources means “re-run against the archived payload for that date,” which is a different operation and needs the source archive. And retries=0 at the DAG level is deliberate: the task already contains a backoff loop, and layering the two produces the multiplication described in the parent topic.
3. Prefect, tasks as plain functions jump to heading
from datetime import date
from prefect import flow, task, get_run_logger
from prefect.futures import wait
from ingest import ingest_source
from infra import Store, Fetcher
SOURCES = ["larimer", "boulder", "weld", "adams"]
@task(retries=0, tags=["municipal-portal"], task_run_name="ingest-{source_id}")
def ingest(source_id: str, logical_date: date):
log = get_run_logger()
r = ingest_source(source_id, Store(), Fetcher(), logical_date)
log.info("%s: %s (%d records)", r.source_id, r.verdict, r.records)
return r
@flow(name="municipal-ingest", log_prints=True)
def municipal_ingest(logical_date: date | None = None):
logical_date = logical_date or date.today()
futures = [ingest.submit(s, logical_date) for s in SOURCES]
wait(futures)
results, failed = [], []
for f in futures:
try:
results.append(f.result())
except Exception as exc: # one source failing is not the run failing
failed.append(str(exc))
held = [r.source_id for r in results if r.verdict == "restated"]
if held or failed:
raise ValueError(f"held={held} failed={failed}")
return sum(r.records for r in results)
The tag on the task is doing real work: a Prefect concurrency limit on municipal-portal caps how many portal-touching tasks run at once, which is the per-host bulkhead expressed in the orchestrator rather than in the fetch layer. And because municipal_ingest is an ordinary function with a default argument, municipal_ingest(date(2026, 8, 4)) is a backfill you can run in a REPL — the property that makes this variant the easiest of the three to test.
Verification & testing jump to heading
The test that matters is the same for all three, and it exercises the shared task body rather than the scheduler:
def test_second_run_is_a_skip(fake_store, fake_fetch):
first = ingest_source("larimer", fake_store, fake_fetch, date(2026, 8, 4))
second = ingest_source("larimer", fake_store, fake_fetch, date(2026, 8, 4))
assert first.verdict == "run" and first.records > 0
assert second.verdict == "skip" and second.records == 0
def test_watermark_is_not_advanced_when_the_write_fails(fake_store, fake_fetch):
fake_store.fail_next_upsert()
before = fake_store.load("larimer")
with pytest.raises(RuntimeError):
ingest_source("larimer", fake_store, fake_fetch, date(2026, 8, 4))
assert fake_store.load("larimer") == before # the important assertion
Then verify the scheduler-specific behaviour once, per framework: that a second concurrent run does not double-fetch (cron: the lease; Airflow: max_active_runs; Prefect: a concurrency limit), and that a source failure does not prevent the other three from completing.
Failure recovery jump to heading
A run half-finished. Because tasks are idempotent per (source, logical_date), the recovery is to run it again. This is the entire payoff of the shared task body: recovery does not depend on which framework you chose.
A watermark advanced wrongly. Restore it from the watermark history and re-run. If the history is not versioned, this becomes a data-repair job — which is why the parent topic insists the watermark table is append-only.
A backfill that fetched current data under a historical date. Identify the affected rows by run_id, retract them, and re-run against the archived payload. This is the failure catchup=False exists to prevent, and it is worth stating in the DAG as a comment because the default invites it.
Frequently asked questions jump to heading
Which one should I actually pick?
Start with cron plus a watermark table and a per-source lease. It is fifty lines, it can be read in one sitting, and for a dozen sources it is genuinely adequate. Move to Prefect when you want task-level concurrency limits and easy local execution without a heavy control plane, and to Airflow when date-parameterised backfills against an archive have become a routine operation rather than an incident. Migrating is cheap precisely because the task body does not import the framework.
Why set catchup=False for municipal feeds?
Because a portal serves current content, not historical content. Replaying past logical dates fetches today’s data and stamps it with a historical date, producing records that claim to describe a state they do not. A genuine backfill for these sources replays the archived payload for that date, which requires the source archive and is a different code path from the scheduled run.
Should retries be configured in the orchestrator or in the task?
In the task, in almost every case. Only the task knows whether a failure was a 429 with a Retry-After header, a malformed payload that will never succeed, or a transport blip — and only the task can honour a circuit breaker. Setting retries in both layers multiplies them, which turns a configuration choice into a retry storm against a portal that is already struggling.
Is a per-source lease really necessary if the schedule is daily?
Yes, because runs overlap for reasons other than frequency: a slow portal, a manual re-run during an incident, a deploy that restarts a worker mid-task. Without a lease, two runs both fetch and both write, and the watermark ends up describing whichever finished last — which may be the older one. The lease must be keyed per source, since a global lock reintroduces the cross-jurisdiction coupling the design is built to avoid.
Related jump to heading
- Parent topic: Scheduling & Orchestration for Municipal Feed Runs
- Section overview: Automated Feed Ingestion & GIS Data Parsing
- Error Handling & Retry Logic — why retries belong in the task rather than the scheduler
- Testing Spatial Data Pipelines — the framework-free task body is what makes the suite possible