Django Celery Integration — Core Concepts
Why Django needs Celery
HTTP requests have a natural time budget — typically under a few seconds. Operations like sending email, processing images, generating reports, or calling third-party APIs can easily exceed that. Running them inside a view blocks the web server thread, delays the user’s response, and risks timeouts.
Celery moves these operations to separate worker processes that run independently of the web server. The web process enqueues a message describing the task, and a Celery worker picks it up and executes it asynchronously.
Architecture overview
The Django-Celery setup has three components:
- Django (producer) — creates task messages and publishes them
- Message broker — stores task messages until workers consume them (Redis or RabbitMQ)
- Celery workers (consumers) — execute the tasks
Django and Celery share the same codebase but run as separate processes. Django serves HTTP requests; Celery workers run in the background.
Setting up the integration
The standard setup involves creating a Celery application instance alongside your Django project:
# project/celery.py
import os
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings')
app = Celery('project')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
# project/__init__.py
from .celery import app as celery_app
__all__ = ('celery_app',)
The autodiscover_tasks() call scans all installed apps for tasks.py files. Configuration uses the CELERY namespace in Django settings:
# settings.py
CELERY_BROKER_URL = 'redis://localhost:6379/0'
CELERY_RESULT_BACKEND = 'redis://localhost:6379/1'
CELERY_TASK_SERIALIZER = 'json'
CELERY_ACCEPT_CONTENT = ['json']
Defining and calling tasks
Tasks are functions decorated with @shared_task:
# myapp/tasks.py
from celery import shared_task
@shared_task
def send_welcome_email(user_id):
user = User.objects.get(id=user_id)
send_email(to=user.email, template='welcome')
Call tasks from views or anywhere else:
# In a view
def register(request):
user = create_user(request.POST)
send_welcome_email.delay(user.id) # Returns immediately
return redirect('registration_complete')
.delay() is shorthand for .apply_async(). The task runs in a worker process, not in the web request. Notice we pass user.id (an integer), not the User object — task arguments must be serializable to JSON.
Tracking results
If you need to check whether a task succeeded or retrieve its return value:
result = send_welcome_email.delay(user.id)
print(result.id) # UUID of the task
print(result.status) # PENDING, STARTED, SUCCESS, FAILURE
print(result.get(timeout=10)) # Wait up to 10s for result
Result tracking requires a result backend (configured via CELERY_RESULT_BACKEND). For fire-and-forget tasks, disable it to reduce overhead: CELERY_TASK_IGNORE_RESULT = True.
Periodic tasks with Celery Beat
Celery Beat is a scheduler that sends tasks to the queue at defined intervals:
# settings.py
from celery.schedules import crontab
CELERY_BEAT_SCHEDULE = {
'daily-report': {
'task': 'reports.tasks.generate_daily_report',
'schedule': crontab(hour=8, minute=0),
},
'cleanup-every-hour': {
'task': 'maintenance.tasks.cleanup_temp_files',
'schedule': 3600.0, # Every 3600 seconds
},
}
Run the beat scheduler alongside workers:
celery -A project beat --loglevel=info
celery -A project worker --loglevel=info
In production, run beat as a single instance — multiple beat processes will duplicate scheduled tasks.
Error handling and retries
Tasks fail. Networks time out, services go down, databases lock. Celery supports automatic retries:
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def call_payment_api(self, order_id):
try:
process_payment(order_id)
except PaymentGatewayTimeout as exc:
raise self.retry(exc=exc)
bind=True gives the task access to self, which provides the retry() method. Each retry re-enqueues the task with exponential backoff available through retry_backoff=True.
Common misconception
Many developers start Celery tasks inside signal handlers or model save() methods. This runs the task dispatch before the database transaction commits — the worker might try to fetch a record that doesn’t exist yet. Always use transaction.on_commit() to dispatch tasks after the transaction is confirmed.
The one thing to remember: Django-Celery integration offloads slow work from web requests to background workers — pass serializable arguments, handle failures with retries, and always dispatch tasks after transaction commit.
See Also
- Python Django Admin Get an intuitive feel for Django Admin so Python behavior stops feeling unpredictable.
- Python Django Basics Get an intuitive feel for Django Basics so Python behavior stops feeling unpredictable.
- Python Django Channels Websockets How Django can send real-time updates to your browser without you refreshing the page.
- Python Django Custom Management Commands How to teach Django new tricks by creating your own command-line shortcuts.
- Python Django Middleware Deep Dive How Django checks, modifies, and guards every web request before it reaches your code.