Choosing Celery vs asyncio for Permit Batch Jobs
Choosing between Celery and plain asyncio for overnight permit batch jobs is a focused architectural decision inside Implementing Async Batch Processing for High-Volume Submissions, part of the broader Automated Permit Ingestion and Parsing Workflows track: it decides whether your overnight import runs as durable distributed tasks or as in-process concurrency, and that choice shapes recoverability, observability, and how much operational weight lands on a small municipal IT team.
The decision is narrow but consequential. An overnight batch import — several thousand permit applications dropped by SFTP, portal bursts queued through the day, inter-agency reconciliations — must finish before the counter opens, survive a worker crash without double-charging a fee, and leave an audit trail a compliance officer can reconcile. Celery gives you distributed workers, durable retries, and scheduled dispatch at the cost of a broker, a result backend, and more moving parts to operate. Plain asyncio gives you thousands of cheap concurrent coroutines in one process with almost no infrastructure, at the cost of durability you must build yourself. Pick wrong and you either over-engineer a nightly job that one process could handle, or you run a business-critical import on in-memory state that vanishes when the box reboots mid-run. This guide frames the trade-offs, shows a minimal implementation of each, and gives a decision matrix you can apply to your own infrastructure.
Permalink to this section The Decision in One Frame
The two tools are not really competitors — they solve problems at different layers, and the honest answer for most municipal stacks is “both, at different levels.” Celery is a distributed task queue: it takes a unit of work, persists it in a broker, dispatches it to a worker process that may live on another host, and tracks the outcome. asyncio is a concurrency primitive: within a single process it interleaves thousands of I/O-bound operations on one event loop. Celery answers “run this reliably, later, somewhere, and tell me what happened”; asyncio answers “run these thousand lookups at once without a thread each.”
For an overnight permit import the questions that actually decide the architecture are: does the job need to survive a mid-run reboot, does it need scheduled dispatch and multi-host fan-out, and how much operational surface can the team carry. The sections below implement each approach at its simplest, then the matrix scores them.
Permalink to this section Approach A: asyncio Alone
When the overnight job is I/O-bound — fetch a batch, enrich each record against county registries, validate, commit — and it comfortably fits one machine, plain asyncio is the lowest-friction choice. There is no broker to run and no result backend to patch. A bounded semaphore caps in-flight work so a surge cannot exhaust the database pool, exactly as the parent async-batch pipeline describes.
import asyncio
MAX_CONCURRENCY = 32 # cap in-flight records; keep <= DB pool size
async def run_overnight_batch(records: list[dict], process, pool) -> dict[str, int]:
"""Enrich + commit a batch entirely in-process, bounded by a semaphore."""
sem = asyncio.Semaphore(MAX_CONCURRENCY)
summary = {"done": 0, "failed": 0}
async def handle(record: dict) -> None:
async with sem: # backpressure: never exceed the cap
async with pool.acquire() as conn:
try:
await process(record, conn)
summary["done"] += 1
except Exception: # in-memory only — a crash loses this
summary["failed"] += 1
await asyncio.gather(*(handle(r) for r in records))
return summary
The catch is durability: if the process is OOM-killed or the VM reboots at 03:00, every in-flight and un-started record is gone, and the job has no memory of how far it got. You can bolt on durability — checkpoint committed keys to a table and resume from there — but at that point you are rebuilding a slice of what Celery gives you for free. asyncio shines when the work is short, idempotent, and cheap to re-run in full.
Permalink to this section Approach B: Celery for Durable, Scheduled Dispatch
When the import must be scheduled, survive crashes, and fan out across hosts, Celery earns its infrastructure. Each batch becomes a task persisted in the broker; a worker on any host picks it up; a crash mid-task means the broker redelivers it rather than dropping it; and Celery beat triggers the run on a cron schedule without an external timer.
from celery import Celery
app = Celery("permits", broker="amqp://mq/permits", backend="redis://cache/1")
@app.task(
bind=True,
acks_late=True, # ack only after success -> crash requeues the task
autoretry_for=(ConnectionError, TimeoutError),
retry_backoff=True, # exponential backoff on transient faults
retry_backoff_max=300,
retry_jitter=True, # jitter avoids synchronized retry storms
max_retries=5,
)
def import_batch(self, batch_id: str, payload_uri: str) -> dict:
"""Durable unit of work: fetch, process, commit one batch by id."""
records = load_payload(payload_uri) # stream raw payload from object store
return process_batch(batch_id, records) # commit is idempotent by batch_id
# Celery beat: schedule the overnight run without an external cron.
app.conf.beat_schedule = {
"nightly-permit-import": {
"task": "permits.import_batch",
"schedule": crontab(hour=2, minute=0), # 02:00 local, off-peak window
"args": ("nightly", "s3://permits-raw/pending/"),
},
}
acks_late=True is the durability lever: the task is acknowledged only after it completes, so a worker crash returns it to the queue for another worker. Pair it with an idempotent commit — keyed by batch or idempotency hash — so a redelivered task cannot double-write. Queue topology, worker lifecycle, and prefetch tuning for exactly this overnight window are covered in depth in Managing Celery Task Queues for Overnight Batch Imports.
Permalink to this section Approach C: Celery Owns Dispatch, asyncio Owns Concurrency
The two combine cleanly, and this is what most production municipal stacks converge on. Celery provides durability, scheduling, retries, and observability at the task level; inside each task, asyncio drives the concurrent I/O so a single worker fetches hundreds of registry lookups at once instead of one at a time.
import asyncio
@app.task(bind=True, acks_late=True, max_retries=5)
def import_batch_async(self, batch_id: str, payload_uri: str) -> dict:
"""Celery task boundary (durable) wrapping an asyncio body (concurrent)."""
# One event loop per task invocation; Celery handles the durable dispatch,
# asyncio handles the fan-out of I/O-bound enrichment inside it.
return asyncio.run(_import_async(batch_id, payload_uri))
async def _import_async(batch_id: str, payload_uri: str) -> dict:
records = await load_payload_async(payload_uri)
sem = asyncio.Semaphore(32)
async def one(r: dict) -> None:
async with sem:
await enrich_and_commit(r) # concurrent registry lookups per record
await asyncio.gather(*(one(r) for r in records))
return {"batch_id": batch_id, "count": len(records)}
This layering gives you Celery’s crash recovery and scheduling with asyncio’s cheap concurrency, and it is why the two rarely belong in an either/or framing once a job is both durable and I/O-bound.
Permalink to this section Comparison Matrix
| Dimension | Celery | asyncio | Rule of thumb |
|---|---|---|---|
| Durability across restart | Broker persists tasks; redelivered after a crash | In-memory; lost unless you checkpoint | Must survive a mid-run reboot → Celery |
| Retries | Declarative autoretry_for + backoff + jitter |
Hand-rolled with tenacity |
Want retries for free → Celery |
| Scheduling | Celery beat cron dispatch | External cron / systemd timer | Self-scheduling job → Celery |
| Distribution | Workers across many hosts | One process, one host | Exceeds one machine → Celery |
| Observability | Flower, task events, per-task state | Your own structured logs | Need per-task audit UI → Celery |
| Operational burden | Broker + result backend to run/patch | Just a Python process | Minimal infra / small team → asyncio |
| Concurrency for I/O | Coarse (process/prefork) | Thousands of cheap coroutines | High-fanout I/O in one task → asyncio |
| Startup complexity | Broker, worker, beat, backend | python job.py |
Prototype / one-off → asyncio |
Permalink to this section Parameter and Flag Reference
| Setting | Applies to | Recommended | Municipal-context notes |
|---|---|---|---|
acks_late |
Celery | True |
Ack after success so a crash requeues the batch instead of losing it. |
max_retries |
Celery | 5 |
Cap retries so a permanently bad batch lands in a dead-letter queue, not a loop. |
retry_backoff / retry_jitter |
Celery | True |
Exponential backoff + jitter prevents synchronized retry storms after an outage. |
worker_prefetch_multiplier |
Celery | 1 |
For long batch tasks, prefetch 1 so work spreads evenly and none is stranded on a crashed worker. |
MAX_CONCURRENCY |
asyncio | 32 |
Semaphore bound on in-flight records; keep at or below the DB connection pool size. |
| loop-per-task | hybrid | asyncio.run |
One event loop per Celery task; do not share a loop across prefork workers. |
visibility_timeout |
Celery + Redis broker | > job runtime |
Must exceed the longest batch runtime or the broker redelivers a task still running. |
Permalink to this section Common Failure Patterns and Fixes
Permalink to this section asyncio job loses hours of work on a crash
An overnight asyncio job holds all progress in memory, so an OOM kill or reboot at 03:00 forces a full re-run — or worse, an ambiguous partial state. Make commits idempotent and checkpoint completed keys so a restart resumes, or move the durability concern to Celery where the broker persists undelivered work.
Permalink to this section Celery task double-writes after a redelivery
acks_late guarantees at-least-once delivery, so a task that crashed after committing but before acking runs again. Guard the commit with an idempotency key and ON CONFLICT DO NOTHING so the second run is a no-op — never assume a Celery task runs exactly once.
# Idempotent commit makes at-least-once safe: a redelivered task is a no-op.
await conn.execute(
"INSERT INTO permits (idempotency_key, payload) VALUES ($1, $2) "
"ON CONFLICT (idempotency_key) DO NOTHING",
key, payload,
)
Permalink to this section Blocking calls stall the asyncio event loop
A synchronous DB driver or a CPU-bound parse inside an async def blocks the whole loop, collapsing concurrency to serial. Use async drivers (asyncpg, httpx) for I/O and offload CPU-bound work to a process pool via run_in_executor so one slow record never freezes the batch.
Permalink to this section Celery broker becomes an unmonitored single point of failure
Adopting Celery adds a broker and result backend that a small team may forget to patch, back up, or alert on — and when the broker is down, nothing runs. Monitor broker health and queue depth as first-class signals, and size the deployment honestly: if the job fits one process, the broker may be complexity you do not need.
Permalink to this section Redis visibility_timeout shorter than the job
With a Redis broker, a visibility_timeout below the batch runtime makes the broker redeliver a task that is still executing, producing duplicate concurrent runs. Set it comfortably above your longest observed batch runtime, and keep commits idempotent as a backstop.
Permalink to this section Audit and Logging Guidance
Whichever model you choose, the compliance requirement is identical: every submission reaches a deterministic terminal state and that transition is recorded. With Celery, capture the task id, batch id, retry count, and final state per task — Flower and task events give per-task history for free, and those records feed the append-only audit trail compliance officers reconcile during public-records requests. With asyncio, you own that trail: emit a structured log line per record with its idempotency key, stage, and disposition, because there is no external task registry to fall back on. In both cases distinguish transient faults (retried) from permanent ones (quarantined) using the triage in Error Handling and Retry Logic for Ingestion Pipelines, and validate each record against the canonical shape in Designing JSON Schemas for Building Permits so a rejected batch is traceable rather than silently dropped. When an enrichment registry is unavailable mid-run, degrade through Building Fallback Routing for Legacy System Downtime instead of failing the whole job. Retain these run records for the state-mandated period; they are the evidence that every overnight import processed each permit exactly once or explicitly quarantined it.
Permalink to this section Frequently Asked Questions
Permalink to this section Is Celery just asyncio with extra steps?
No — they operate at different layers. asyncio interleaves concurrent I/O inside one process and one event loop; Celery persists units of work in a broker and dispatches them to worker processes that may run on other hosts, surviving restarts. asyncio gives you concurrency; Celery gives you durability, scheduling, and distribution. The common production answer is a Celery task whose body uses asyncio for its lookups.
Permalink to this section When is plain asyncio the right call for an overnight batch?
When the job is I/O-bound, fits comfortably on one machine, and is cheap and safe to re-run in full — an idempotent enrichment pass, for example. You avoid running and patching a broker and result backend, and a bounded semaphore already protects the database. Reach for Celery once the job must be scheduled, must survive a mid-run crash without re-running everything, or must fan out across hosts.
Permalink to this section How do I stop a Celery task from processing the same batch twice?
Assume at-least-once delivery and make the commit idempotent. Derive a deterministic key from the batch contents, enforce it with a unique database constraint, and write with ON CONFLICT DO NOTHING, so a redelivered task after a crash becomes a no-op. Also keep the broker visibility_timeout above your longest batch runtime so a still-running task is not redelivered underneath you.
Permalink to this section Related
- Implementing Async Batch Processing for High-Volume Submissions — the parent section defining the batch pipeline this choice sits inside.
- Managing Celery Task Queues for Overnight Batch Imports — queue topology and worker lifecycle once you pick Celery.
- Error Handling and Retry Logic for Ingestion Pipelines — the transient-vs-permanent triage both models rely on.
- Cache Warming Strategies for Permit Lookup APIs — the warmed read layer enrichment lookups hit inside either model.