Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Python background job patterns including task queues, workers, and event-driven architecture. Use when implementing async task processing, job queues, long-running operations, or decoupling work from request/response cycles.
.claude/skills/dicklesworthstone-python-background-jobs/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 12% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 87% | 0% |
| case-17 | ✗→✓ | ▲ Improved | 105% | 0% |
| case-16 | ✓→✓ | = Same ✓ | 96% | 0% |
| case-07 | ✓→✓ | = Same ✓ | 54% | 0% |
Decouple long-running or unreliable work from request/response cycles. Return immediately to the user while background workers handle the heavy lifting asynchronously.
API accepts request, enqueues a job, returns immediately with a job ID. Workers process jobs asynchronously.
Tasks may be retried on failure. Design for safe re-execution.
Jobs transition through states: pending → running → succeeded/failed.
Most queues guarantee at-least-once delivery. Your code must handle duplicates.
This skill uses Celery for examples, a widely adopted task queue. Alternatives like RQ, Dramatiq, and cloud-native solutions (AWS SQS, GCP Tasks) are equally valid choices.
pythonfrom celery import Celery app = Celery("tasks", broker="redis://localhost:6379") @app.task def send_email(to: str, subject: str, body: str) -> None: # This runs in a background worker email_client.send(to, subject, body) # In your API handler send_email.delay("user@example.com", "Welcome!", "Thanks for signing up")
For operations exceeding a few seconds, return a job ID and process asynchronously.
pythonfrom uuid import uuid4 from dataclasses import dataclass from enum import Enum from datetime import datetime class JobStatus(Enum): PENDING = "pending" RUNNING = "running" SUCCEEDED = "succeeded" FAILED = "failed" @dataclass class Job: id: str status: JobStatus created_at: datetime started_at: datetime | None = None completed_at: datetime | None = None result: dict | None = None error: str | None = None # API endpoint async def start_export(request: ExportRequest) -> JobResponse: """Start export job and return job ID.""" job_id = str(uuid4()) # Persist job record await jobs_repo.create(Job( id=job_id, status=JobStatus.PENDING, created_at=datetime.utcnow(), )) # Enqueue task for background processing await task_queue.enqueue( "export_data", job_id=job_id, params=request.model_dump(), ) # Return immediately with job ID return JobResponse( job_id=job_id, status="pending", poll_url=f"/jobs/{job_id}", )
Configure Celery tasks with proper retry and timeout settings.
pythonfrom celery import Celery app = Celery("tasks", broker="redis://localhost:6379") # Global configuration app.conf.update( task_time_limit=3600, # Hard limit: 1 hour task_soft_time_limit=3000, # Soft limit: 50 minutes task_acks_late=True, # Acknowledge after completion task_reject_on_worker_lost=True, worker_prefetch_multiplier=1, # Don't prefetch too many tasks ) @app.task( bind=True, max_retries=3, default_retry_delay=60, autoretry_for=(ConnectionError, TimeoutError), ) def process_payment(self, payment_id: str) -> dict: """Process payment with automatic retry on transient errors.""" try: result = payment_gateway.charge(payment_id) return {"status": "success", "transaction_id": result.id} except PaymentDeclinedError as e: # Don't retry permanent failures return {"status": "declined", "reason": str(e)} except TransientError as e: # Retry with exponential backoff raise self.retry(exc=e, countdown=2 ** self.request.retries * 60)
Workers may retry on crash or timeout. Design for safe re-execution.
python@app.task(bind=True) def process_order(self, order_id: str) -> None: """Process order idempotently.""" order = orders_repo.get(order_id) # Already processed? Return early if order.status == OrderStatus.COMPLETED: logger.info("Order already processed", order_id=order_id) return # Already in progress? Check if we should continue if order.status == OrderStatus.PROCESSING: # Use idempotency key to avoid double-charging pass # Process with idempotency key result = payment_provider.charge( amount=order.total, idempotency_key=f"order-{order_id}", # Critical! ) orders_repo.update(order_id, status=OrderStatus.COMPLETED)
Idempotency Strategies:
INSERT ... ON CONFLICT UPDATEPersist job state transitions for visibility and debugging.
pythonclass JobRepository: """Repository for managing job state.""" async def create(self, job: Job) -> Job: """Create new job record.""" await self._db.execute( """INSERT INTO jobs (id, status, created_at) VALUES ($1, $2, $3)""", job.id, job.status.value, job.created_at, ) return job async def update_status( self, job_id: str, status: JobStatus, **fields, ) -> None: """Update job status with timestamp.""" updates = {"status": status.value, **fields} if status == JobStatus.RUNNING: updates["started_at"] = datetime.utcnow() elif status in (JobStatus.SUCCEEDED, JobStatus.FAILED): updates["completed_at"] = datetime.utcnow() await self._db.execute( "UPDATE jobs SET status = $1, ... WHERE id = $2", updates, job_id, ) logger.info( "Job status updated", job_id=job_id, status=status.value, )
Handle permanently failed tasks for manual inspection.
python@app.task(bind=True, max_retries=3) def process_webhook(self, webhook_id: str, payload: dict) -> None: """Process webhook with DLQ for failures.""" try: result = send_webhook(payload) if not result.success: raise WebhookFailedError(result.error) except Exception as e: if self.request.retries >= self.max_retries: # Move to dead letter queue for manual inspection dead_letter_queue.send({ "task": "process_webhook", "webhook_id": webhook_id, "payload": payload, "error": str(e), "attempts": self.request.retries + 1, "failed_at": datetime.utcnow().isoformat(), }) logger.error( "Webhook moved to DLQ after max retries", webhook_id=webhook_id, error=str(e), ) return # Exponential backoff retry raise self.retry(exc=e, countdown=2 ** self.request.retries * 60)
Provide an endpoint for clients to check job status.
pythonfrom fastapi import FastAPI, HTTPException app = FastAPI() @app.get("/jobs/{job_id}") async def get_job_status(job_id: str) -> JobStatusResponse: """Get current status of a background job.""" job = await jobs_repo.get(job_id) if job is None: raise HTTPException(404, f"Job {job_id} not found") return JobStatusResponse( job_id=job.id, status=job.status.value, created_at=job.created_at, started_at=job.started_at, completed_at=job.completed_at, result=job.result if job.status == JobStatus.SUCCEEDED else None, error=job.error if job.status == JobStatus.FAILED else None, # Helpful for clients is_terminal=job.status in (JobStatus.SUCCEEDED, JobStatus.FAILED), )
Compose complex workflows from simple tasks.
pythonfrom celery import chain, group, chord # Simple chain: A → B → C workflow = chain( extract_data.s(source_id), transform_data.s(), load_data.s(destination_id), ) # Parallel execution: A, B, C all at once parallel = group( send_email.s(user_email), send_sms.s(user_phone), update_analytics.s(event_data), ) # Chord: Run tasks in parallel, then a callback # Process all items, then send completion notification workflow = chord( [process_item.s(item_id) for item_id in item_ids], send_completion_notification.s(batch_id), ) workflow.apply_async()
Choose the right tool for your needs.
RQ (Redis Queue): Simple, Redis-based
pythonfrom rq import Queue from redis import Redis queue = Queue(connection=Redis()) job = queue.enqueue(send_email, "user@example.com", "Subject", "Body")
Dramatiq: Modern Celery alternative
pythonimport dramatiq from dramatiq.brokers.redis import RedisBroker dramatiq.set_broker(RedisBroker()) @dramatiq.actor def send_email(to: str, subject: str, body: str) -> None: email_client.send(to, subject, body)
Cloud-native options:
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-16 | pass→pass | 11,570 | 7,532 | -35% | 1 | 1 | 0% | 2,137 | 4,179 | +96% | 0 | 0 | — |
case-07 | pass→pass | 21,187 | 14,756 | -30% | 1 | 1 | 0% | 3,542 | 5,463 | +54% | 0 | 0 | — |
case-01 | fail→pass | 30,773 | 31,591 | +3% | 1 | 1 | 0% | 5,891 | 6,622 | +12% | 0 | 0 | — |
case-02 | pass→pass | 17,672 | 17,298 | -2% | 1 | 1 | 0% | 3,134 | 5,862 | +87% | 0 | 0 | — |
case-03 | pass→pass | 9,366 | 8,020 | -14% | 1 | 1 | 0% | 1,643 | 4,059 | +147% | 0 | 0 | — |
case-04 | pass→pass | 14,264 | 14,136 | -1% | 1 | 1 | 0% | 2,736 | 5,720 | +109% | 0 | 0 | — |
case-05 | fail→pass | 22,216 | 26,088 | +17% | 1 | 1 | 0% | 3,062 | 5,718 | +87% | 0 | 0 | — |
case-06 | pass→pass | 16,251 | 17,003 | +5% | 1 | 1 | 0% | 2,879 | 5,988 | +108% | 0 | 0 | — |
case-08 | pass→pass | 11,308 | 10,324 | -9% | 1 | 1 | 0% | 2,447 | 5,101 | +108% | 0 | 0 | — |
case-09 | pass→pass | 7,475 | 8,019 | +7% | 1 | 1 | 0% | 1,445 | 4,365 | +202% | 0 | 0 | — |
case-10 | pass→pass | 10,687 | 9,528 | -11% | 1 | 1 | 0% | 1,741 | 4,565 | +162% | 0 | 0 | — |
case-11 | pass→pass | 18,030 | 11,143 | -38% | 1 | 1 | 0% | 1,958 | 4,706 | +140% | 0 | 0 | — |
case-12 | pass→pass | 14,425 | 5,679 | -61% | 1 | 1 | 0% | 2,240 | 3,869 | +73% | 0 | 0 | — |
case-13 | pass→pass | 11,967 | 7,284 | -39% | 1 | 1 | 0% | 1,904 | 4,145 | +118% | 0 | 0 | — |
case-14 | pass→pass | 13,357 | 10,614 | -21% | 1 | 1 | 0% | 2,852 | 4,891 | +71% | 0 | 0 | — |
case-15 | pass→pass | 12,101 | 13,871 | +15% | 1 | 1 | 0% | 2,229 | 5,136 | +130% | 0 | 0 | — |
case-17 | fail→pass | 10,059 | 6,506 | -35% | 1 | 1 | 0% | 1,966 | 4,036 | +105% | 0 | 0 | — |
case-18 | pass→pass | 15,315 | 14,439 | -6% | 1 | 1 | 0% | 2,755 | 5,352 | +94% | 0 | 0 | — |
case-19 | pass→pass | 14,167 | 15,991 | +13% | 1 | 1 | 0% | 2,577 | 5,634 | +119% | 0 | 0 | — |
case-20 | pass→pass | 12,303 | 8,751 | -29% | 1 | 1 | 0% | 2,333 | 4,459 | +91% | 0 | 0 | — |
case-21 | pass→pass | 4,754 | 5,730 | +21% | 1 | 1 | 0% | 621 | 3,504 | +464% | 0 | 0 | — |
case-22 | pass→pass | 13,773 | 18,560 | +35% | 1 | 1 | 0% | 2,991 | 5,801 | +94% | 0 | 0 | — |
case-23 | pass→pass | 9,693 | 11,367 | +17% | 1 | 1 | 0% | 1,611 | 4,815 | +199% | 0 | 0 | — |
case-24 | fail→fail | 22,890 | 23,310 | +2% | 1 | 1 | 0% | 3,462 | 7,288 | +111% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 24 cases were attempted. The headline lift of +13 percentage points is the difference between those two pass rates over the 24 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.