Sitemap

7 Celery/RQ Patterns That Dramatically Simplify Python Services

Practical recipes to queue work, tame retries, and keep your workers boringly reliable — without building a distributed maze.

--

Press enter or click to view image in full size

Seven copy-pasteable Celery/RQ workflows that simplify Python background jobs: setup, retries, timeouts, observability, testing, and safe concurrency.

Background jobs shouldn’t feel like a second product. Yet many teams end up with bespoke orchestration, mystery failures, and “why did that task run twice?” threads. Let’s make it boring again. Below are seven field-tested patterns to keep Celery or RQ clean, predictable, and production-ready — without turning your app into a scheduler theme park.

1) Minimal “one-file” setup you can reason about

Goal: Start with a single place to read, write, and reason about your worker configuration.

Celery (Redis broker)

# worker.py
import os
from celery import Celery

REDIS = os.getenv("REDIS_URL", "redis://localhost:6379/0")
celery = Celery("svc", broker=REDIS, backend=REDIS)

celery.conf.update(
task_serializer="json",
accept_content=["json"],
result_serializer="json",
task_acks_late=True, # safe with idempotent tasks
worker_prefetch_multiplier=1
)

@celery.task
def resize_image(url: str) -> str:
# ... do work ...
return "ok"

Run:

celery -A worker.celery worker --loglevel=INFO --concurrency=4
# Optional periodic jobs: celery -A worker.celery beat

RQ (Redis + simple workers)

# jobs.py
import os, redis
from rq import Queue
from rq.decorators import job

REDIS = redis.from_url(https://medium.com/os.getenv("REDIS_URL",%20"redis://localhost:6379/0"))
q = Queue("default", connection=REDIS)

@job("default", connection=REDIS, timeout=600)
def resize_image(url: str) -> str:
# ... do work ...
return "ok"

# enqueue: jobs.resize_image.delay("https://...")

Run:

rq worker default --url redis://localhost:6379/0
# Scheduler (periodic): rqscheduler --url redis://localhost:6379/0

Why this helps: one file, explicit imports, boring defaults. You can toggle brokers, add queues, or set serializers without hunting across the codebase.

2) Idempotence + retries with exponential backoff

If a job can be retried, it must be idempotent. Store a task-key (e.g., hash of inputs) and short-circuit if already completed. Then apply retries with backoff.

Celery

from celery import shared_task
from celery.exceptions import MaxRetriesExceededError

@shared_task(
bind=True,
autoretry_for=(ConnectionError, TimeoutError),
retry_backoff=True, # 1s, 2s, 4s...
retry_jitter=True, # add jitter to avoid thundering herd
retry_kwargs={"max_retries": 5}
)
def fetch_and_cache(self, key: str):
if already_done(key): # e.g., Redis SETNX or DB unique row
return "cached"
try:
data = call_api(key)
persist(key, data)
return "ok"
except Exception as e:
raise e

RQ

from rq import Retry
job = jobs.resize_image.delay("https://...", retry=Retry(max=5, interval=[1,2,4,8,16]))

Sanity check: retries don’t fix logic bugs, but they do handle flaky I/O and transient outages. Keep the max small; failures should surface.

3) Typed payloads with Pydantic to stabilize contracts

Drifted payloads are the top cause of “it worked locally.” Make inputs explicit and versioned.

# contracts.py
from pydantic import BaseModel, HttpUrl
from typing import Optional

class ResizeRequestV1(BaseModel):
url: HttpUrl
width: int
height: int
correlation_id: Optional[str] = None

Use in tasks:

@shared_task
def resize_image_v1(payload: dict):
req = ResizeRequestV1.model_validate(payload) # raises on bad input
# use req.url, req.width...

Why it helps: you can evolve to ResizeRequestV2 without surprising workers in flight. Validation failures appear early, with clear errors.

4) Timeouts, hard/soft kill, and graceful cancellation

Jobs that never end will eat your cluster.

Celery

celery.conf.update(
task_time_limit=300, # hard kill
task_soft_time_limit=240 # gives you a chance to clean up
)

@shared_task(bind=True)
def slow_op(self):
try:
do_work()
except SoftTimeLimitExceeded:
cleanup_and_log(self.request.id)
raise

RQ

# set per-job timeout
jobs.resize_image.delay("https://...", timeout=600)

Tip: combine timeouts with visibility timeouts in your broker (or acks_late=True in Celery) so stuck workers don’t “lose” jobs.

5) Safe concurrency: rate limits, queues, and isolation

Don’t put every task on one queue. Group by impact (external APIs, CPU heavy, DB touchy), then set limits.

Celery

# two queues: api and cpu
celery.conf.task_queues = (
{"name": "api"}, {"name": "cpu"}
)

@shared_task(rate_limit="10/m", queue="api")
def call_partner_api(...): ...

@shared_task(queue="cpu")
def bulk_thumbnail(...): ...

Start dedicated workers:

celery -A worker.celery worker -Q api --concurrency=2
celery -A worker.celery worker -Q cpu --concurrency=6

RQ

rq worker api --url $REDIS_URL  # small concurrency
rq worker cpu --url $REDIS_URL # larger concurrency

Pattern: isolate risky or expensive tasks to their own queue and worker pool. It’s an easy throttle that prevents “one job to rule them all” incidents.

6) Observability that actually answers “what happened?”

If you can’t trace a job across services, you’ll guess. Add correlation IDs, structured logs, and minimal metrics.

# logging.py
import json, logging, os
logger = logging.getLogger("svc")
logger.setLevel(logging.INFO)
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter('%(message)s'))
logger.addHandler(handler)

def log(event: str, **fields):
print(json.dumps({"event": event, **fields}))

In tasks (Celery)

@shared_task(bind=True)
def resize_image(self, payload: dict):
cid = payload.get("correlation_id") or self.request.id
log("task.start", task=self.name, cid=cid)
try:
# ...
log("task.success", cid=cid)
except Exception as e:
log("task.fail", cid=cid, error=str(e))
raise

In RQ

# inside job function
log("task.start", job_id=get_current_job().id)

Minimal metrics to emit:

  • queue depth (Redis LLEN)
  • task latency (enqueue → start)
  • success/fail rates per task
  • retry counts

Even a CSV/JSON log shipped to your dashboard will save hours.

7) Local-first testing: run jobs eagerly (Celery) or with a fake worker (RQ)

You don’t need a full cluster to test logic.

Celery: eager mode

def create_celery(testing=False):
app = Celery("svc", broker="redis://localhost:6379/0")
if testing:
app.conf.update(task_always_eager=True, task_eager_propagates=True)
return app

Then in pytest:

def test_resize(tmp_path, monkeypatch):
from worker import resize_image
result = resize_image.delay({"url": "https://...", "width": 64, "height": 64})
assert result.get() == "ok"

RQ: SimpleWorker + FakeRedis

import redis
from rq import SimpleWorker
from rq.job import Job
from jobs import q, resize_image

def test_job_exec():
job = q.enqueue(resize_image, "https://...")
w = SimpleWorker([q], connection=q.connection)
w.work(burst=True) # executes one job then exits
assert Job.fetch(job.id, connection=q.connection).is_finished

Why this matters: you prove business logic without external dependencies. CI stays fast, and production surprises shrink.

Tiny case study: cutting queue latency 70% with two tweaks

A team had a single “default” queue mixing CPU-heavy image work and API calls. During traffic spikes, API tasks starved and SLOs cratered.

Get Hash Block’s stories in your inbox

Join Medium for free to get updates from this writer.

We applied:

  1. Queue isolation: api (rate-limited workers) vs cpu (bigger pool).
  2. Retry + jitter for flaky partner calls.

Result: API task p95 latency dropped from ~8.2s to ~2.3s. They didn’t add servers; they added boredom — in the best way.

Pragmatic checklist (stick on your README)

  • One-file worker config with explicit queues
  • Idempotent tasks + limited retries with jitter
  • Pydantic-validated payloads, versioned models
  • Timeouts (Celery soft/hard; RQ per-job)
  • Queue isolation + rate limits by impact domain
  • Correlation IDs + structured logs + basic metrics
  • Eager or SimpleWorker tests in CI

Follow this and your background jobs become… unremarkable. That’s the point.

Conclusion

Celery and RQ aren’t complicated; our systems make them complicated. Start with clear contracts, add principled timeouts, keep queues purpose-built, and make retries polite. Then wire up just enough observability to answer “what happened?” without starting a forensic investigation. You’ll ship faster, debug less, and sleep better.

CTA: What pattern saved your queue on a bad day? Drop it in the comments. If you want a printable checklist or sample repo for both Celery and RQ, say the word — I’ll share a distilled starter kit.

--

--

Hash Block
Hash Block

Learn With Us. AI | ML | Programing | Blockchain | Crypto | NFT