I have been a fan of Oban in the Elixir ecosystem for a while now. It is one of those libraries that does exactly what it says and gets out of your way. So when the team announced an official Python port earlier this year, I wanted to take it for a spin and see how the experience translates.
The pitch is simple: PostgreSQL-backed job processing with no message broker. No Redis, no RabbitMQ, no separate infrastructure to manage. Jobs live in the same database as your application data, which means you can enqueue a job inside the same transaction that creates a user record and know they either both commit or neither does. Coming from a Python ecosystem where Celery + Redis has been the default for over a decade, that is a meaningful simplification.
I put together a demo project that exercises every major feature Oban offers. This post walks through what I found.
The Basics: Workers and Queues
Oban workers are classes decorated with @worker. You define a process method that receives a job object with your arguments, attempt metadata, tags, and more.
from oban import worker
@worker(queue="mailers")
class SendNotification:
async def process(self, job):
to = job.args["to"]
subject = job.args["subject"]
await smtp.send(to, subject, job.args["body"])
Enqueueing is straightforward:
await SendNotification.enqueue({
"to": "v@nightcity.net",
"subject": "Welcome",
"body": "You're in, choom.",
})
Workers support priority levels (0 through 9, where 0 is highest), tags for grouping and filtering, and configurable retry limits. You can override any of these at enqueue time:
@worker(queue="reports", priority=3, max_attempts=5, tags=["export", "data"])
class DataExportWorker:
async def process(self, job):
if "urgent" in job.tags:
# Fast-track this one
...
await DataExportWorker.enqueue(
{"dataset": "corpo-intel", "format": "csv"},
tags=["export", "data", "urgent"],
priority=1,
)
Queues are configured with independent concurrency limits. A slow report queue at 2 concurrent jobs won’t starve your email queue running at 20. This is configured either in oban.toml for the CLI or programmatically:
[queues]
default = 10
mailers = 5
reports = 2
maintenance = 1
Scheduling: Delayed and Periodic Jobs
For one-off delayed jobs, pass schedule_in at enqueue time:
from datetime import timedelta
await SendReminder.enqueue(
{"to": "v@nightcity.net", "message": "Check your cyberware diagnostics"},
schedule_in=timedelta(seconds=30),
)
# Integer seconds also work
await SendReminder.enqueue(
{"to": "judy@moxes.net", "message": "BD editing session at midnight"},
schedule_in=3600,
)
The job sits in a scheduled state until its time arrives, consuming no worker resources.
For recurring work, attach a cron expression directly to the worker. Oban’s leader election ensures periodic jobs are only inserted once across the cluster, regardless of how many worker nodes are running.
@worker(queue="maintenance", cron="0 * * * *")
class HourlyHealthCheck:
async def process(self, job):
await run_health_checks()
@worker(queue="maintenance", cron="0 3 * * *")
class DailyPruneWorker:
async def process(self, job):
await prune_old_records()
Standard 5-field cron expressions, aliases like @daily and @hourly, and per-worker timezone overrides are all supported:
@worker(queue="reports", cron={"expr": "0 9 * * MON-FRI", "timezone": "America/New_York"})
class BusinessHoursReport:
async def process(self, job):
await generate_report()
Job Lifecycle: Snooze, Cancel, and Record
This is where Oban gets more interesting than most job libraries. Beyond the typical “succeed or raise an exception” flow, Oban gives workers three additional return types that model common real-world patterns.
Snooze re-enqueues a job after a delay without counting as a failure. This is useful when you are polling an external resource that is not ready yet. Each snooze increments a counter in job.meta["snoozed"] so you can set limits:
from oban import worker, Snooze
@worker(queue="default", max_attempts=10)
class PollingWorker:
async def process(self, job):
status = await check_report_status(job.args["report_id"])
if status == "pending":
if job.meta.get("snoozed", 0) >= 5:
raise RuntimeError("Resource never became ready")
return Snooze(seconds=10)
await process_report(job.args["report_id"])
Cancel gracefully stops a job without marking it as a failure. Think of it as a clean exit for jobs whose preconditions are no longer valid:
from oban import worker, Cancel
@worker(queue="default")
class GracefulCancelWorker:
async def process(self, job):
user = await get_user(job.args.get("user_id"))
if not user:
return Cancel("User not found")
for i, item in enumerate(job.args["items"]):
if job.cancelled():
return Cancel(f"Cancelled after {i} items")
await process_item(item)
The job.cancelled() check enables cooperative cancellation for long-running work. When someone calls oban.cancel_job(id), the database state updates immediately, but the worker can check and stop gracefully rather than being killed mid-operation.
Record captures structured output from a completed job. The result is stored in PostgreSQL alongside the job, which is useful for audit trails, downstream consumers, or just being able to answer “what did that job produce?”
from oban import worker, Record
@worker(queue="reports")
class AnalysisWorker:
async def process(self, job):
result = await run_analysis(job.args["dataset_id"])
return Record({
"score": result.score,
"metrics": {
"precision": result.precision,
"recall": result.recall,
},
"computed_at": datetime.now(timezone.utc).isoformat(),
})
Custom Backoff and Error Introspection
Workers can define a backoff method to control retry intervals. The default is a jittery clamped exponential backoff, but you can replace it with whatever makes sense for your domain:
@worker(queue="default", max_attempts=5)
class RetryWithBackoff:
async def process(self, job):
if job.attempt > 1 and job.errors:
last_error = job.errors[-1]
if "timeout" in last_error.get("error", "").lower():
await process_with_extended_timeout()
return
await process_normally()
def backoff(self, job):
return 15 * job.attempt # Linear: 15s, 30s, 45s, 60s
The ability to inspect job.errors on retries is something I appreciate. Rather than blindly retrying the same way, you can look at what went wrong and adapt. A timeout on the previous attempt? Try with a longer deadline. A rate limit? Back off more aggressively.
Runtime Queue Control
This is one of the features that separates Oban from simpler job libraries. You can pause, resume, and scale queues at runtime without restarting any workers:
# Maintenance window: stop taking new work
await oban.pause_queue("default")
# Maintenance complete, resume
await oban.resume_queue("default")
# Traffic spike: scale up concurrency
await oban.scale_queue(queue="default", limit=50)
# Back to normal
await oban.scale_queue(queue="default", limit=10)
These operations are cluster-wide by default (all nodes see the change) but can target specific nodes when needed. You can also introspect queue state:
for info in oban.check_all_queues():
print(f"{info.queue}: {len(info.running)}/{info.limit}, paused={info.paused}")
For our demo, I wired these up as FastAPI endpoints so you can manage queues over HTTP:
@app.post("/queues/{queue}/pause")
async def pause_queue(queue: str):
await oban.pause_queue(queue)
return {"queue": queue, "action": "paused"}
@app.post("/queues/{queue}/scale")
async def scale_queue(queue: str, limit: int = 10):
await oban.scale_queue(queue=queue, limit=limit)
return {"queue": queue, "action": "scaled", "limit": limit}
Deployment Topology: Client vs. Worker
A pattern I like in the demo is separating the enqueueing side from the processing side. The FastAPI app creates an Oban instance with no queues, making it a pure client:
@asynccontextmanager
async def lifespan(_app: FastAPI):
pool = await Oban.create_pool(dsn=DSN)
oban = Oban(pool=pool) # No queues = client-only
async with oban:
yield
A separate process runs with queues configured to actually process jobs:
oban = Oban(
pool=pool,
queues={
"default": 10,
"mailers": 5,
"reports": 2,
"maintenance": 1,
},
)
async with oban:
await asyncio.Event().wait()
This maps to a common production topology: web nodes enqueue, worker nodes process. The only shared infrastructure is PostgreSQL, which you almost certainly already have.
Oban also ships with a CLI that auto-discovers your workers and cron schedules:
oban start --dsn postgresql://localhost/mydb --queues default:10,mailers:5
Batch Enqueue
For inserting multiple jobs atomically:
await oban.enqueue_many(
SendNotification.new({"to": "t-bug@nc.net", "subject": "Batch 1", "body": "..."}),
SendNotification.new({"to": "jackie@nc.net", "subject": "Batch 2", "body": "..."}),
SendNotification.new({"to": "panam@nc.net", "subject": "Batch 3", "body": "..."}),
)
All jobs are inserted in a single transaction. Either they all make it in or none of them do.
What I Liked
The PostgreSQL-only architecture is genuinely simpler. No Redis to keep running, no connection pooling to a second data store, no split-brain scenarios between your application database and your job queue. In a world where most applications already have PostgreSQL, adding Oban is just a pip install and a migration.
Job retention is the right default. Most job libraries delete jobs after completion. Oban keeps them, giving you a full audit trail. The pruner handles cleanup on your terms (max_age = 86400 for 24-hour retention, or whatever you want).
Runtime queue control is operationally useful. Being able to pause a queue during a maintenance window or scale up during a traffic spike without touching deployments is the kind of thing you do not appreciate until you need it at 2am.
The worker API is clean. @worker with process(self, job) gives you everything you need. The Snooze/Cancel/Record return types model real patterns that other libraries handle with awkward workarounds. Custom backoff() is a nice touch.
What to Watch For
It is early. This is a 0.x release, not a 1.0. I hit a quirk where the @job function decorator double-binds arguments through enqueue and new, so I stuck with @worker classes throughout. Not a big deal since @worker is the more capable API anyway, but worth knowing.
No unique jobs (yet). If you need deduplication (only one pending instance of a particular job at a time), that is not in the Python version yet. Oban for Elixir has it, so presumably it will arrive.
Oban Pro features are a separate license. Workflows (fan-out/fan-in), smart rate limiting, and multi-process execution require Oban Pro for Python, which is a paid add-on. The OSS version covers everything shown in this post.
Try It
The demo project uses Docker (for PostgreSQL) and uv for Python dependency management, with just as a task runner:
just setup # Postgres + deps + migrations
just worker # Terminal 1: start processing
just demo # Terminal 2: exercise all features
Or if you want the FastAPI interface for managing queues over HTTP:
just worker # Terminal 1
just api # Terminal 2: http://localhost:8000/docs
The Oban for Python documentation is well-organized, and the introductory post covers the motivation and design philosophy.
Wrapping Up
If you are starting a new Python service today and reaching for Celery out of habit, Oban is worth a serious look. The operational footprint is smaller (one database instead of two), the API is leaner, and the lifecycle primitives (Snooze, Cancel, Record, custom backoff) model things you would otherwise hand-roll. It is still early, but the trajectory is good and the Elixir version is what proves the design works at scale.
Further Reading
- Oban for Python Documentation — Official docs covering all features
- Introducing Oban for Python — Announcement post from the Oban team
- oban-bg/oban-py — Source code on GitHub
- Oban for Elixir — The original, if you are curious about where all this comes from