A third-party backend for Django's Tasks framework (introduced in Django 6.0), using PostgreSQL for queue storage and fair-queuing via per-tenant concurrency slots.
Why
django_tasksand notdjango.tasks? The Tasks API was added in Django 6.0. If you're on an older Django (5.x), you can use thedjango-tasksbackport package — it provides the same API under thedjango_tasksnamespace. pgsq depends ondjango-tasksso it works on both Django 5.x and 6.x. When you upgrade to Django 6.0, swap the package and switch imports todjango.tasks.
Features:
- PostgreSQL-backed — no Redis or other infra required
- Fair queuing — per-tenant slot limits prevent noisy neighbours from starving other tenants
- Exponential backoff — failed tasks retry with doubling delays (1s → 3s → 7s → …)
- Async worker — non-blocking enqueue; worker threads drain the queue asynchronously
- Queue introspection — query results via
get_result()or inspect thepgsq_tasktable
# settings.py
INSTALLED_APPS = [
...,
"django_tasks",
"pgsq",
]
TASKS = {
"default": {
"BACKEND": "pgsq.backend.PgsqBackend",
},
}# myapp/tasks.py
from django_tasks import task
@task()
def send_welcome_email(user_email: str) -> str:
# ...
return f"sent to {user_email}"
# enqueue (returns immediately)
result = send_welcome_email.enqueue("user@example.com")Run migrations, then start a worker:
./manage.py pgsq_worker --num=4from django_tasks import task
@task()
def add(a: int, b: int) -> int:
return a + b
result = add.enqueue(21, 21)
# result.id — opaque tracking id
# result.status — "READY" | "RUNNING" | "SUCCESSFUL" | "FAILED"from django_tasks import default_task_backend
result = default_task_backend.get_result(result_id)
# result.return_value — the task's return value (SUCCESSFUL only)
# result.errors[0].traceback — exception traceback (FAILED only)
result.refresh()Each task carries a tenant (the tenant_id column of pgsq_task). The
worker's fair-queuing slots key off it, so one busy tenant can't starve the
rest. Set the tenant per-enqueue with .using(tenant_id=...):
result = send_welcome_email.using(tenant_id="user@example.com").enqueue(
"user@example.com"
)Tasks enqueued without a tenant land on the "default" tenant. Configure
per-tenant concurrency limits:
from pgsq.models import PgsqTaskSlot
PgsqTaskSlot.objects.create(tenant_id="user@example.com", slots=5)
PgsqTaskSlot.objects.create(tenant_id="another@example.com", slots=2)Tenants with no slot record default to 3 concurrent tasks.
# 4 worker threads
./manage.py pgsq_worker --num=4
# Ctrl+C to stopThe worker logs lifecycle events for each task:
03:03:58 [INFO] Claimed task abc123...
03:03:58 [INFO] Executing myapp.tasks.send_welcome_email (abc123...)
03:03:58 [INFO] Task abc123... finished: sent to user@example.com
03:03:58 [INFO] Task abc123... (myapp.tasks.send_welcome_email) failed — retrying in 5s (ETA 03:04:06)
# Overview: counts by status, stale-RUNNING detection, per-tenant breakdown
./manage.py pgsq_admin
# Filter to a single tenant
./manage.py pgsq_admin --tenant user@example.com
# Full details for a single task (status, timing, errors, retry info)
./manage.py pgsq_admin inspect <task_id>
# Reap zombie RUNNING tasks older than 10 minutes (default threshold)
./manage.py pgsq_admin reap # reap stale tasks
./manage.py pgsq_admin reap --dry-run # preview only
./manage.py pgsq_admin reap --all # emergency: reap ALL RUNNING
# Enqueue a test task (built-in: add, hello, fail — or any dotted path)
./manage.py pgsq_admin enqueue add --args '[2, 3]'
./manage.py pgsq_admin enqueue hello --args '["world"]' --tenant user@example.com
./manage.py pgsq_admin enqueue fail # enqueue a task that always fails
# Custom stale threshold
./manage.py pgsq_admin --threshold 30The worker automatically reaps stale RUNNING tasks (default: 10 minutes) at
the top of each poll cycle, so crashed/hung worker threads don't permanently
hold a tenant's fair-queue slots. Use the reap subcommand for manual
cleanup or emergency cleanup (--all).
Stores one queued/executed task. Fields mirror Django's TaskResult dataclass. The table is created by running migrate.
Per-tenant concurrency limit. When a tenant reaches their slot limit, no new tasks for that tenant are picked up until one finishes.
git clone <repo>
cd pgsq
uv pip install -e .Run tests:
uv run python test_pgsq.py