Background job processing for Python web applications. PostgreSQL or SQLite. No Redis required.
- Installation
- Quick Start
- Framework Setup
- Flask
- FastAPI
- SQLite (Development)
- Django (coming soon)
- Defining Jobs
- Enqueueing Jobs
- Queues
- Retry Policies
- Scheduled (Cron) Jobs
- Running Workers
- Dashboard
- Middleware
- Configuration Reference
- Practical Examples
- Testing
- Error Handling & Monitoring
- FAQ
Requirements:
- Python 3.10+
- PostgreSQL 12+ (production) or SQLite 3.24+ (development)
- A Flask, FastAPI, Django, or Sanic application
pip install crazyjobWith optional extras:
pip install "crazyjob[flask]" # Flask + PostgreSQL
pip install "crazyjob[fastapi]" # FastAPI integration
pip install "crazyjob[postgresql]" # PostgreSQL driver only (no framework)SQLite is included in Python's standard library — no extra install needed.
The fastest way to get CrazyJob running in a Flask app.
1. Install
pip install "crazyjob[flask]"2. Configure and initialize
# app.py
from flask import Flask
from crazyjob.integrations.flask import FlaskCrazyJob
app = Flask(__name__)
app.config["CRAZYJOB_DATABASE_URL"] = "postgresql://user:password@localhost/mydb"
cj = FlaskCrazyJob(app)3. Define a job
# jobs/email_jobs.py
from crazyjob import Job
class WelcomeEmailJob(Job):
queue = "mailers"
def perform(self, user_id: int):
user = User.query.get(user_id)
send_welcome_email(user.email)4. Enqueue it
from jobs.email_jobs import WelcomeEmailJob
@app.route("/register", methods=["POST"])
def register():
user = create_user(request.json)
WelcomeEmailJob.enqueue(user_id=user.id)
return jsonify({"status": "ok"}), 2015. Create the database tables
crazyjob migrate6. Start a worker
crazyjob worker --queues mailers,default7. Open the dashboard
http://localhost:5000/crazyjob
# app.py
from flask import Flask
from crazyjob.integrations.flask import FlaskCrazyJob
app = Flask(__name__)
app.config.update(
CRAZYJOB_DATABASE_URL="postgresql://user:password@localhost/mydb",
CRAZYJOB_QUEUES=["critical", "default", "low"],
CRAZYJOB_DEFAULT_MAX_ATTEMPTS=3,
CRAZYJOB_DASHBOARD_ENABLED=True,
CRAZYJOB_DASHBOARD_PREFIX="/crazyjob",
)
cj = FlaskCrazyJob(app)# extensions.py
from crazyjob.integrations.flask import FlaskCrazyJob
cj = FlaskCrazyJob()
# app.py
from flask import Flask
from extensions import cj
def create_app(config=None):
app = Flask(__name__)
app.config.from_object(config or "config.DevelopmentConfig")
cj.init_app(app)
# ... other extensions
return appComing soon. The Django integration is on the roadmap. The configuration will look like:
# settings.py
INSTALLED_APPS = [
# ...
"crazyjob.integrations.django",
]
CRAZYJOB = {
"DATABASE_URL": "postgresql://user:password@localhost/mydb",
"QUEUES": ["critical", "default", "low"],
"DASHBOARD_ENABLED": True,
"DASHBOARD_PREFIX": "/crazyjob",
}
# urls.py
from django.urls import path, include
urlpatterns = [
path("crazyjob/", include("crazyjob.dashboard.adapters.django.urls")),
]# main.py
from fastapi import FastAPI
from crazyjob.integrations.fastapi import FastAPICrazyJob
app = FastAPI()
cj = FastAPICrazyJob(app, settings={
"database_url": "postgresql://user:password@localhost/mydb",
"queues": ["critical", "default", "low"],
"dashboard_enabled": True,
"dashboard_prefix": "/crazyjob",
})# extensions.py
from crazyjob.integrations.fastapi import FastAPICrazyJob
cj = FastAPICrazyJob()
# main.py
from fastapi import FastAPI
from extensions import cj
def create_app():
app = FastAPI()
cj.init_app(app, settings={
"database_url": "postgresql://user:password@localhost/mydb",
})
return appcj = FastAPICrazyJob(app, settings={
"database_url": "sqlite:///crazyjob_dev.db",
})SQLite is ideal for local development and testing — no PostgreSQL server required.
# Flask
app.config["CRAZYJOB_DATABASE_URL"] = "sqlite:///crazyjob_dev.db"
# FastAPI
cj = FastAPICrazyJob(app, settings={
"database_url": "sqlite:///crazyjob_dev.db",
})
# CLI
export CRAZYJOB_DATABASE_URL="sqlite:///crazyjob_dev.db"
crazyjob migrate
crazyjob worker --queues defaultLimitations:
- Single writer (serialized writes via
BEGIN IMMEDIATE) - No
SELECT ... FOR UPDATE SKIP LOCKED— uses Python-level locking - Suitable for development and single-machine deployments, not high-throughput production
Every job is a class that inherits from crazyjob.Job and implements a perform() method.
from crazyjob import Job
class SendInvoiceJob(Job):
queue = "mailers"
def perform(self, invoice_id: int, email: str):
invoice = Invoice.query.get(invoice_id)
send_email(to=email, attachment=invoice.pdf)Rules for perform() arguments:
- Use only JSON-serializable types:
str,int,float,bool,list,dict,None datetimeandUUIDobjects are automatically serialized and deserialized- Do not pass ORM model instances — pass IDs and re-fetch inside
perform()
# ✅ Good — pass the ID
UserReportJob.enqueue(user_id=user.id)
# ❌ Bad — model instances are not safely serializable
UserReportJob.enqueue(user=user)from crazyjob import Job
from datetime import timedelta
class MyJob(Job):
# Queue this job runs in (default: "default")
queue = "critical"
# Maximum execution attempts before moving to dead letters (default: 3)
max_attempts = 5
# Retry backoff strategy: "linear", "exponential", "exponential_cap", or a callable
retry_backoff = "exponential"
# Add ±10% jitter to retry delays to avoid thundering herd (default: True)
retry_jitter = True
# Job-level execution timeout (default: None — no timeout)
timeout = timedelta(minutes=10)
# Priority: 0 = highest, 100 = lowest (default: 50)
priority = 10
def perform(self, ...):
...# With keyword arguments (recommended)
SendInvoiceJob.enqueue(invoice_id=123, email="user@example.com")
# With positional arguments
SendInvoiceJob.enqueue(123, "user@example.com")
# Capture the job ID
job_id = SendInvoiceJob.enqueue(invoice_id=123, email="user@example.com")
print(f"Enqueued job {job_id}")from datetime import timedelta
# Run 30 minutes from now
SendInvoiceJob.enqueue_in(timedelta(minutes=30), invoice_id=123, email="user@example.com")
# Run in 2 hours
SendInvoiceJob.enqueue_in(timedelta(hours=2), invoice_id=123, email="user@example.com")
# Run tomorrow
SendInvoiceJob.enqueue_in(timedelta(days=1), invoice_id=123, email="user@example.com")from datetime import datetime, timezone
run_time = datetime(2026, 3, 10, 9, 0, tzinfo=timezone.utc)
SendInvoiceJob.enqueue_at(run_time, invoice_id=123, email="user@example.com")Queues are just strings. You don't need to register them in advance — a queue is created the moment a job is enqueued to it.
class CriticalJob(Job):
queue = "critical"
class MailerJob(Job):
queue = "mailers"
class ReportJob(Job):
queue = "reports"Workers specify which queues they consume:
# Only consume critical and mailers
crazyjob worker --queues critical,mailers
# Consume all queues
crazyjob worker --all-queuesWhen a worker is assigned multiple queues, it checks them in order — queues listed first are polled first, giving them higher throughput.
Jobs within the same queue can have a numeric priority. Lower numbers are processed first.
class UrgentJob(Job):
queue = "default"
priority = 0 # processed first
class BackgroundJob(Job):
queue = "default"
priority = 100 # processed lastclass MyJob(Job):
max_attempts = 5
retry_backoff = "exponential"| Strategy | Delays for attempts 1 through 5 |
|---|---|
"linear" |
30s → 1m → 1.5m → 2m → 2.5m |
"exponential" |
30s → 1m → 2m → 4m → 8m |
"exponential_cap" |
30s → 1m → 2m → 4m → 1h (capped) |
Pass a callable that receives the attempt number (1-indexed) and returns a timedelta:
from datetime import timedelta
from crazyjob import Job
def fibonacci_backoff(attempt: int) -> timedelta:
a, b = 1, 1
for _ in range(attempt - 1):
a, b = b, a + b
return timedelta(seconds=a * 30)
class MyJob(Job):
max_attempts = 8
retry_backoff = fibonacci_backoffclass NoRetryJob(Job):
max_attempts = 1 # fails immediately to dead letters on first errorfrom crazyjob import Retry
class SometimesFlaky(Job):
max_attempts = 5
def perform(self, resource_id: int):
resource = fetch_resource(resource_id)
if resource.is_locked:
# Retry in exactly 60 seconds, regardless of backoff policy
raise Retry(in_seconds=60, reason="Resource is locked")
process(resource)Define recurring jobs using standard cron expressions.
# schedules.py
from crazyjob import Job, schedule
# Every day at 9 AM on weekdays
@schedule(cron="0 9 * * 1-5", name="daily_report")
class DailyReportJob(Job):
queue = "reports"
def perform(self):
generate_and_email_daily_report()
# Every hour
@schedule(cron="0 * * * *", name="hourly_cleanup")
class CleanupJob(Job):
def perform(self):
delete_expired_sessions()
# Every 5 minutes
@schedule(cron="*/5 * * * *", name="health_check")
class HealthCheckJob(Job):
def perform(self):
ping_external_services()Run the scheduler as a separate process:
crazyjob schedulerThe scheduler uses
SELECT ... FOR UPDATE SKIP LOCKEDon the schedules table, so running multiple scheduler processes is safe — only one will fire each schedule at the correct time.
# Single queue, 5 concurrent threads
crazyjob worker --queues default --concurrency 5
# Multiple queues (polled in order)
crazyjob worker --queues critical,mailers,default --concurrency 10
# All queues
crazyjob worker --all-queues --concurrency 8| Option | Default | Description |
|---|---|---|
--queues |
default |
Comma-separated list of queues to consume |
--all-queues |
off | Consume all queues found in the database |
--concurrency |
5 |
Number of concurrent worker threads |
--processes |
1 |
Number of worker processes (for CPU-bound jobs) |
--poll-interval |
1.0 |
Seconds between fetch attempts when queue is empty |
--shutdown-timeout |
30 |
Seconds to wait for in-flight jobs before force exit |
Systemd service:
# /etc/systemd/system/crazyjob-worker.service
[Unit]
Description=CrazyJob Worker
After=network.target postgresql.service
[Service]
Type=simple
User=appuser
WorkingDirectory=/var/www/myapp
Environment=FLASK_APP=app.py
Environment=CRAZYJOB_DATABASE_URL=postgresql://user:password@localhost/mydb
ExecStart=/var/www/myapp/venv/bin/crazyjob worker --queues critical,default,mailers --concurrency 10
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.targetsudo systemctl enable crazyjob-worker
sudo systemctl start crazyjob-workerDocker:
CMD ["crazyjob", "worker", "--all-queues", "--concurrency", "10"]# docker-compose.yml
services:
web:
build: .
command: flask run
worker:
build: .
command: crazyjob worker --all-queues --concurrency 10
environment:
- CRAZYJOB_DATABASE_URL=postgresql://user:password@db/mydb
depends_on:
- db
scheduler:
build: .
command: crazyjob scheduler
environment:
- CRAZYJOB_DATABASE_URL=postgresql://user:password@db/mydb
depends_on:
- db
db:
image: postgres:16app.config.update(
CRAZYJOB_DASHBOARD_ENABLED=True,
CRAZYJOB_DASHBOARD_PREFIX="/crazyjob", # default
)Navigate to http://localhost:5000/crazyjob.
| Page | URL | What you see |
|---|---|---|
| Overview | /crazyjob/ |
Job counts per status, throughput graph, error rate |
| Enqueued | /crazyjob/queues |
Jobs waiting to run, grouped by queue |
| Active | /crazyjob/active |
Jobs running right now, with elapsed time |
| Scheduled | /crazyjob/scheduled |
Jobs with a future run_at |
| Retrying | /crazyjob/retrying |
Failed jobs waiting for next retry attempt |
| Completed | /crazyjob/completed |
History of successful jobs with latency |
| Failed | /crazyjob/failed |
Recent failures with full stacktrace |
| Dead | /crazyjob/dead |
Jobs that exhausted all attempts |
| Workers | /crazyjob/workers |
Active workers, heartbeat status, current job |
| Schedules | /crazyjob/schedules |
Cron job registry, enable/disable, manual trigger |
Actions available from the dashboard:
- 🔁 Resurrect a dead job (re-enqueue it)
- 🔁 Bulk resurrect all dead letters
- ❌ Cancel an enqueued job (before it's picked up)
- ⏸️ Pause / resume a queue
- 🗑️ Clear all jobs in a queue
▶️ Trigger a schedule manually
Basic auth (simplest):
app.config["CRAZYJOB_DASHBOARD_AUTH"] = ("admin", "s3cr3t-p4ssw0rd")Custom auth function:
def my_auth_check(request) -> bool:
return request.headers.get("X-Internal-Token") == os.environ["INTERNAL_TOKEN"]
app.config["CRAZYJOB_DASHBOARD_AUTH"] = my_auth_checkFlask-Login integration:
from flask_login import current_user
def require_admin(request) -> bool:
return current_user.is_authenticated and current_user.is_admin
app.config["CRAZYJOB_DASHBOARD_AUTH"] = require_adminCrazyJob provides a Middleware abstract base class with three hooks: before_perform, after_perform, and on_failure. You write your own middleware classes to integrate with any logging or monitoring tool.
from crazyjob.core.middleware import Middleware
from crazyjob.core.job import JobRecord
class LoggingMiddleware(Middleware):
def before_perform(self, job: JobRecord) -> None:
print(f"Starting {job.class_path} (attempt {job.attempts})")
def after_perform(self, job: JobRecord, result: object) -> None:
print(f"Completed {job.class_path}")
def on_failure(self, job: JobRecord, error: Exception) -> None:
print(f"Failed {job.class_path}: {error}")
cj = FlaskCrazyJob(app)
cj.use(LoggingMiddleware())from crazyjob.core.middleware import Middleware
from crazyjob.core.job import JobRecord
import time
class TimingMiddleware(Middleware):
def before_perform(self, job: JobRecord) -> None:
job.meta["started_at"] = time.monotonic()
def after_perform(self, job: JobRecord, result) -> None:
elapsed = time.monotonic() - job.meta["started_at"]
print(f"{job.class_path} completed in {elapsed:.2f}s")
def on_failure(self, job: JobRecord, error: Exception) -> None:
print(f"{job.class_path} failed: {error}")
alert_on_call_team(job, error)
# Register globally
cj.use(TimingMiddleware())All settings can be provided via app.config (Flask), settings.py (Django), or environment variables.
| Config Key | Env Variable | Default | Description |
|---|---|---|---|
CRAZYJOB_DATABASE_URL |
CRAZYJOB_DATABASE_URL |
required | PostgreSQL DSN |
CRAZYJOB_QUEUES |
CRAZYJOB_QUEUES |
["default"] |
Default queues for workers |
CRAZYJOB_DEFAULT_MAX_ATTEMPTS |
— | 3 |
Global default for max_attempts |
CRAZYJOB_DEFAULT_BACKOFF |
— | "exponential" |
Global retry strategy |
CRAZYJOB_POLL_INTERVAL |
— | 1.0 |
Seconds between fetch attempts |
CRAZYJOB_JOB_TIMEOUT |
— | None |
Default job timeout (seconds) |
CRAZYJOB_DEAD_LETTER_TTL_DAYS |
— | 30 |
Auto-purge dead letters after N days |
CRAZYJOB_DASHBOARD_ENABLED |
— | True |
Enable the web dashboard |
CRAZYJOB_DASHBOARD_PREFIX |
— | "/crazyjob" |
URL prefix for the dashboard |
CRAZYJOB_DASHBOARD_AUTH |
— | None |
Tuple, callable, or None |
CRAZYJOB_HEARTBEAT_INTERVAL |
— | 10 |
Worker heartbeat frequency (seconds) |
CRAZYJOB_DEAD_WORKER_THRESHOLD |
— | 60 |
Seconds before worker is considered dead |
# jobs/email_jobs.py
from crazyjob import Job
from myapp.email import send_email
from myapp.models import User
class WelcomeEmailJob(Job):
queue = "mailers"
max_attempts = 3
retry_backoff = "exponential"
def perform(self, user_id: int):
user = User.query.get(user_id)
if not user:
return # User deleted — skip silently
send_email(
to=user.email,
subject="Welcome!",
template="welcome",
context={"name": user.first_name}
)
@app.route("/register", methods=["POST"])
def register():
user = User.create(**request.json)
db.session.commit()
WelcomeEmailJob.enqueue(user_id=user.id)
return jsonify(user.to_dict()), 201# jobs/file_jobs.py
from crazyjob import Job
class ProcessUploadJob(Job):
queue = "default"
timeout = timedelta(minutes=15)
max_attempts = 2
def perform(self, upload_id: int):
upload = Upload.query.get(upload_id)
upload.status = "processing"
db.session.commit()
try:
result = run_image_pipeline(upload.s3_key)
upload.status = "complete"
upload.result_url = result.url
except Exception:
upload.status = "failed"
raise # let CrazyJob handle retry
finally:
db.session.commit()
@app.route("/upload", methods=["POST"])
def upload_file():
file = request.files["file"]
s3_key = upload_to_s3(file)
upload = Upload.create(s3_key=s3_key, status="pending")
db.session.commit()
ProcessUploadJob.enqueue(upload_id=upload.id)
return jsonify({"upload_id": upload.id, "status": "pending"}), 202# jobs/webhook_jobs.py
from crazyjob import Job
from crazyjob import Retry
import httpx
class DeliverWebhookJob(Job):
queue = "webhooks"
max_attempts = 10
retry_backoff = "exponential_cap"
def perform(self, webhook_id: int):
webhook = Webhook.query.get(webhook_id)
payload = build_payload(webhook)
try:
response = httpx.post(
webhook.url,
json=payload,
timeout=10,
headers={"X-Webhook-Signature": sign(payload, webhook.secret)}
)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 60))
raise Retry(in_seconds=retry_after, reason="Rate limited")
if response.status_code >= 500:
raise Retry(reason=f"Server error {response.status_code}")
response.raise_for_status()
webhook.last_delivered_at = datetime.utcnow()
db.session.commit()
except httpx.TimeoutException:
raise Retry(reason="Request timed out")# jobs/report_jobs.py
from crazyjob import Job, schedule
from datetime import timedelta
class GenerateReportJob(Job):
queue = "reports"
timeout = timedelta(minutes=30)
priority = 80
def perform(self, org_id: int, report_type: str, email_to: str):
org = Organization.query.get(org_id)
data = fetch_report_data(org, report_type)
pdf_path = render_pdf(data, template=report_type)
s3_url = upload_to_s3(pdf_path)
send_email(
to=email_to,
subject=f"Your {report_type} report is ready",
template="report_ready",
context={"download_url": s3_url}
)
@app.route("/reports/request", methods=["POST"])
def request_report():
GenerateReportJob.enqueue(
org_id=current_user.org_id,
report_type=request.json["type"],
email_to=current_user.email
)
return jsonify({"message": "Report generation started. You'll receive an email shortly."}), 202
# Also runs every Monday at 8 AM
@schedule(cron="0 8 * * 1", name="weekly_report")
class WeeklyReportJob(Job):
queue = "reports"
def perform(self):
for org in Organization.query.filter_by(weekly_report=True).all():
GenerateReportJob.enqueue(
org_id=org.id,
report_type="weekly_summary",
email_to=org.billing_email
)Enqueue the next job at the end of perform(). Simple and fully traceable in the dashboard.
class Step1Job(Job):
queue = "pipeline"
def perform(self, order_id: int):
result = validate_order(order_id)
Step2Job.enqueue(order_id=order_id, validation=result)
class Step2Job(Job):
queue = "pipeline"
def perform(self, order_id: int, validation: dict):
charge_customer(order_id, validation)
Step3Job.enqueue(order_id=order_id)
class Step3Job(Job):
queue = "pipeline"
def perform(self, order_id: int):
send_order_confirmation(order_id)Fan out a list of IDs into individual jobs:
class SyncAllUsersJob(Job):
queue = "sync"
def perform(self):
user_ids = db.session.query(User.id).all()
for (user_id,) in user_ids:
SyncUserJob.enqueue(user_id=user_id)
class SyncUserJob(Job):
queue = "sync"
max_attempts = 3
def perform(self, user_id: int):
user = User.query.get(user_id)
sync_to_crm(user)Test your job's perform() method directly — just instantiate the job class and call it:
# tests/test_jobs.py
def test_welcome_email_is_sent(mocker):
mock_send = mocker.patch("myapp.email.send_email")
user = UserFactory.create()
job = WelcomeEmailJob()
job.perform(user_id=user.id)
mock_send.assert_called_once_with(
to=user.email,
subject="Welcome!",
template="welcome",
context={"name": user.first_name}
)Use pytest-postgresql to test the full enqueue → fetch → execute cycle with a real database:
# conftest.py
import pytest
from crazyjob.backends.postgresql import PostgreSQLDriver
from crazyjob.backends.postgresql.schema import apply_schema
from crazyjob.core.client import Client, set_client
@pytest.fixture
def backend(postgresql):
dsn = f"postgresql://{postgresql.info.user}@{postgresql.info.host}:{postgresql.info.port}/{postgresql.info.dbname}"
driver = PostgreSQLDriver(dsn=dsn)
apply_schema(driver)
return driver
@pytest.fixture
def cj_client(backend):
client = Client(backend=backend)
set_client(client)
return client# tests/test_enqueue.py
def test_job_is_enqueued(cj_client, backend):
job_id = WelcomeEmailJob.enqueue(user_id=42)
record = backend.get_job(job_id)
assert record is not None
assert record.status == "enqueued"
assert record.kwargs == {"user_id": 42}Failed and dead jobs store the full Python traceback in the database. You can inspect them from the Failed and Dead pages in the dashboard, or query directly:
SELECT class_path, error, attempts FROM cj_jobs WHERE status = 'failed';Write a custom middleware to capture exceptions with Sentry:
import sentry_sdk
from crazyjob.core.middleware import Middleware
class SentryMiddleware(Middleware):
def on_failure(self, job, error):
sentry_sdk.capture_exception(error)
sentry_sdk.init(dsn=os.environ["SENTRY_DSN"])
cj.use(SentryMiddleware())from crazyjob.core.middleware import Middleware
class AlertMiddleware(Middleware):
def on_failure(self, job, error):
if job.attempts >= job.max_attempts:
notify_slack(
f"💀 Job {job.class_path} has died after {job.attempts} attempts.\n"
f"Error: {error}\n"
f"Dashboard: https://myapp.com/crazyjob/dead"
)Q: Can I use CrazyJob without a web framework, in a plain Python script?
Yes. Use the core directly without any framework integration:
from crazyjob.backends.postgresql import PostgreSQLDriver
from crazyjob.core.client import Client, set_client
from myapp.jobs import MyJob
backend = PostgreSQLDriver(dsn="postgresql://...")
client = Client(backend=backend)
set_client(client)
MyJob.enqueue(user_id=1)Q: What happens to jobs if the worker crashes mid-execution?
If a worker process crashes (OOM kill, kill -9, power loss), its heartbeat stops updating. After 60 seconds (configurable), another worker detects the stale heartbeat and re-enqueues any jobs that were active on the dead worker. They will be retried normally.
Q: Can I run multiple workers?
Yes. Start as many worker processes as you need. SELECT ... FOR UPDATE SKIP LOCKED guarantees each job is picked up by exactly one worker, even with dozens of workers running in parallel across multiple machines.
Q: Does CrazyJob support async jobs (async def perform)?
Not in the current version. perform() must be synchronous. For async frameworks like FastAPI, the worker runs synchronous jobs in a thread pool. Native async def perform() support is planned for a future release.
Q: How do I clear old completed jobs to save disk space?
From the dashboard: go to Completed → Clear All.
Via CLI:
crazyjob purge --status completed --older-than 30d
crazyjob purge --status dead --older-than 7dQ: Is CrazyJob safe to use with PgBouncer?
Yes, with transaction-mode pooling. CrazyJob uses short-lived transactions for job fetching. Avoid session-mode pooling as it may interfere with SKIP LOCKED.
For architecture details, backend internals, and integration guides for framework authors, see ARCHITECTURE.md.