Dewey
Guaranteed delivery engine for Python. Postgres is the scheduler and the backlog; your broker is just a worker pool.
Most task queues keep the backlog in Redis. A lost message is then lost work, a crashed worker leaves nothing behind to explain itself, and "what is still pending for this customer?" has no good answer. Dewey inverts that: a task is a Postgres row from the moment it exists, a dispatcher hands ready rows to your broker, and the broker's only job is carrying a task ID to a worker.
Losing the broker costs you latency. It cannot cost you work.
Install
pip install dewey # core only
pip install "dewey[sqlalchemy]" # SQLAlchemy models + sync API
pip install "dewey[sqlalchemy,async]" # SQLAlchemy sync + async API
pip install "dewey[django]" # Django models + API
pip install "dewey[huey]" # Huey transport adapter
Requires Python 3.11+ and PostgreSQL 13+.
Which extra do I need? Dewey integrates at the database layer, not the web layer, so pick by ORM:
| You use | Install | Why |
|---|---|---|
| Django | dewey[django] |
Django models and migrations ship with it, plus a dewey_dispatcher management command |
| FastAPI, Starlette, Litestar, Flask + SQLAlchemy | dewey[sqlalchemy,async] (or drop async for sync) |
Dewey never touches your web framework — it only needs your ORM |
| No web framework at all | dewey[sqlalchemy] |
A script or worker fleet is a first-class consumer |
There is no fastapi extra because there is nothing for it to install: an async FastAPI
app is a SQLAlchemy async consumer, and that path has its own dispatcher
(AsyncDispatcher) so an asyncpg deployment never needs a synchronous driver.
Quickstart
1. Declare the task. Handlers stay ordinary functions. Policy sits next to them, as data.
import dewey
@dewey.task("agent.notify", max_attempts=5, backoff=dewey.Constant(3))
def notify_agent(command_id: str) -> None:
command = Command.objects.get(id=command_id)
if command.is_terminal:
return # already handled; nothing to do
try:
agent_channel.send(command.id)
except AgentOffline as exc:
raise dewey.TransientError(str(exc)) # retry per policy
except MalformedCommand as exc:
raise dewey.NonRetryableError(str(exc)) # dead-letter now, don't burn attempts
2. Create work inside your own transaction. Producers never import handlers and never touch the broker.
from dewey.django import create_task
with transaction.atomic():
command = Command.objects.create(...)
create_task(task_type="agent.notify", args=[str(command.id)])
# Roll back, and neither the command nor the task ever existed.
3. Run the dispatcher. It claims ready rows, hands IDs to the transport, and runs the recovery sweep.
python manage.py dewey_dispatcher
4. Run a worker. Ordinary Huey. For Django, dewey.contrib.django_huey is the
wiring: importing it registers Dewey's processor on huey.contrib.djhuey.HUEY exactly
once, with Huey retries disabled and close_db around each task.
# settings.py
HUEY = {...} # Huey's normal Django configuration
DEWEY = {"DISPATCH": "dewey.contrib.django_huey.dispatch"}
# myapp/tasks.py — imported by Huey's normal Django task discovery
from dewey.contrib.django_huey import adapter, dispatch # noqa: F401
python manage.py run_huey
DEWEY["DISPATCH"] must name a module-level callable — it is resolved with Django's
import_string, so an object traversal such as "myapp.tasks.adapter.dispatch" cannot
be imported. If you wire Huey yourself, expose a module-level wrapper:
# myapp/tasks.py
from huey.contrib.djhuey import HUEY, close_db
from dewey.adapters.huey import HueyAdapter
from dewey.django import process_task
adapter = HueyAdapter(HUEY)
adapter.register(close_db(process_task))
def dispatch(task_id: str):
return adapter.dispatch(task_id) # DEWEY = {"DISPATCH": "myapp.tasks.dispatch"}
Check the active setup and dispatcher readiness with python manage.py dewey_doctor
(or --format json for monitoring/CI). It validates configuration and schema, reports
what the in-process handler registry can and cannot prove, and fails closed without a
fresh database-backed dispatcher heartbeat.
That is the whole loop. SQLAlchemy — sync and async — works the same way; see docs/getting-started.md.
What you get
- A committed task is a task that will run. The row and the wake-up commit together, so a rolled-back transaction leaves nothing behind and a committed one is never forgotten.
- Retries, backoff and dead-lettering as policy, resolved in one place instead of scattered across decorators and handler bodies. Handlers never sleep, never retry themselves.
- Crash recovery you can reason about. A dispatcher that dies mid-dispatch, a worker killed mid-task, a broker that drops a message: each is a state in the ledger, with a sweep that reclaims it.
- A queryable backlog.
SELECT count(*) FROM task_entries WHERE status = 'pending'is the answer, not a Redis introspection script. - Duplicate delivery is harmless. Claims are atomic, so a redelivered task ID is a
logged no-op. Producers that need request idempotency can use
create_or_get_task(); conflicting reuse raisesIdempotencyConflictErrorwithout logging argument values. - Deadlines are native.
expires_atis an absolute aware timestamp. Dewey recordsEXPIREDbefore dispatch and again before handler invocation, without consuming an attempt;now == expires_atis expired. - An audit trail: attempts, errors, timestamps and correlation metadata per row.
How it fits together
producer Postgres dispatcher worker
─────────────────────────────────────────────────────────────────────────────────
create_task() ──────► task_entries
(pending)
│ NOTIFY on commit
▼
claim (SKIP LOCKED) ◄──── LISTEN + poll
(dispatching) ─────────► dispatch(task_id) ──► broker
│
(processing) ◄────────────────────────────── process_task
(completed / failed / dead)
The state machine:
PENDING ──► DISPATCHING ──► PROCESSING ──► COMPLETED
▲ │ │
│ │ ├──► FAILED ──► DISPATCHING (direct retry when due)
│ │ │ └───► DEAD (attempts exhausted)
└─────────────┴───────────────┘ (recovery sweep)
└──────────────► EXPIRED ◄──────────────┘ (deadline before start)
PENDING → PROCESSING is also legal, for in-process execution with no broker in the
path. DEAD → PENDING is a manual retry.
Operational notes
- The dispatcher must be running for retries to happen. Due
FAILEDrows are claimed directly and the dispatcher wakes at the earliest known retry/schedule; the periodic sweep is crash recovery, not ordinary retry scheduling. - Polling is the correctness path; LISTEN is an optimisation. The dispatcher polls regardless, which is what recovers missed notifications and newly-due scheduled work.
dispatch_timeout_secondsmust exceed your worst-case broker backlog, or work that is merely waiting gets reclaimed and dispatched twice.- Dewey owns retry, not your broker. The Huey adapter registers with
retries=0on purpose: two retry engines over one task is how work runs twice. - Give Dewey its own bounded connection pool when it shares a database with your request handlers, so background pressure cannot become user-visible latency. See sharing a database.
- Producer, dispatcher and worker are three database roles. The producer's
create_taskmust use the same alias — and therefore the same connection and transaction — as the business write it belongs to; that is what makes the rollback guarantee real. The dispatcher (DEWEY["DATABASE"]) and worker (DEWEY["WORKER_DATABASE"]) open their own connections after commit. Two aliases pointing at one physical Postgres are still two connections and two transactions, so never route producerTaskEntrywrites to a background alias.
Documentation
| Guide | What it covers |
|---|---|
| Getting started | SQLAlchemy sync, SQLAlchemy async, and Django, end to end, plus sharing a database with your app |
| Concepts | States, claims, policy resolution, and the limits of what Dewey guarantees |
| Adapters | The transport contract, and writing your own |
| From Huey or Celery | Pattern-by-pattern migration, one task type at a time |
| Query API | Backlog, stuck work, dead letters, manual retry, purging |
| Logging | Correlation metadata across producer, dispatcher and worker |
Stability
On 0.x the public API may change between minor versions. 1.0 waits until the API has
been proven by real production use.
The release is deliberately one thing: durable task delivery. Multi-channel notification delivery is not part of it — an earlier parallel ledger was removed before publishing rather than shipped half-committed. Future notification tooling can build channel handlers on ordinary Dewey tasks without adding another execution engine.
Development
make install # install with dev dependencies
make up # Postgres + Redis for the test suite
make test-integration # run the suite against them
make wheel-smoke # build a wheel and exercise it in a clean venv
make lint typecheck # ruff + basedpyright
make down
The suite runs against real Postgres by design: FOR UPDATE SKIP LOCKED, partial
indexes, LISTEN/NOTIFY and committed claims cannot be proven against a fake.
Acknowledgements
Thanks to Chad Whitacre, the original owner of the dewey PyPI project, for kindly donating the package name.
License
MIT — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file dewey-0.5.1.tar.gz.
File metadata
- Download URL: dewey-0.5.1.tar.gz
- Upload date:
- Size: 81.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
731fd2c803c0980f43164cc75c04422928091fde0994d4b0fa4b2690f3298087
|
|
| MD5 |
dd008616262e321801948a9adcf56d9e
|
|
| BLAKE2b-256 |
5f81f684e97e9e05d3272b12d1707e8027d1209e3d444a515d32bb3fe8a5a52b
|
Provenance
The following attestation bundles were made for dewey-0.5.1.tar.gz:
Publisher:
publish.yml on frankapps-labs/dewey
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
dewey-0.5.1.tar.gz -
Subject digest:
731fd2c803c0980f43164cc75c04422928091fde0994d4b0fa4b2690f3298087 - Sigstore transparency entry: 2440236282
- Sigstore integration time:
-
Permalink:
frankapps-labs/dewey@f3cec0b8f100253924d53aef30b52fc9173f0188 -
Branch / Tag:
refs/tags/v0.5.1 - Owner: https://github.com/frankapps-labs
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f3cec0b8f100253924d53aef30b52fc9173f0188 -
Trigger Event:
push
-
Statement type:
File details
Details for the file dewey-0.5.1-py3-none-any.whl.
File metadata
- Download URL: dewey-0.5.1-py3-none-any.whl
- Upload date:
- Size: 93.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
34f40c32052c802556a7be25b2767934acbe8e06e2c7bc2eb8b9406616132add
|
|
| MD5 |
1a8f617b2e0c4df5e527540aeb812ba9
|
|
| BLAKE2b-256 |
d3556e11a3a74f8a3982880eb8d1e7b8213c799a075b0363b838da3c832a8a9a
|
Provenance
The following attestation bundles were made for dewey-0.5.1-py3-none-any.whl:
Publisher:
publish.yml on frankapps-labs/dewey
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
dewey-0.5.1-py3-none-any.whl -
Subject digest:
34f40c32052c802556a7be25b2767934acbe8e06e2c7bc2eb8b9406616132add - Sigstore transparency entry: 2440236317
- Sigstore integration time:
-
Permalink:
frankapps-labs/dewey@f3cec0b8f100253924d53aef30b52fc9173f0188 -
Branch / Tag:
refs/tags/v0.5.1 - Owner: https://github.com/frankapps-labs
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f3cec0b8f100253924d53aef30b52fc9173f0188 -
Trigger Event:
push
-
Statement type: