The Celery app, tasks, workers, and task discovery
coreintermediateA Celery deployment has three pieces. The **app** is a `Celery(...)` instance created once in `config/celery.py` and imported from `__init__.py` so it exists before any task is defined. A **task** is a function registered with that app — in a Django project you use `@shared_task`, which registers with whichever app is configured rather than binding to one instance, so reusable apps work. A **worker** is a separate process (`celery -A config worker`) that consumes messages and runs those functions; it is not part of your web server and does not share its memory. **Task discovery** is `app.autodiscover_tasks()`, which imports `tasks.py` from every entry in `INSTALLED_APPS` — which is why a task in a file with any other name is invisible.
Think of it as
The single most useful thing to internalise is that calling a task does not run it. `send_email.delay(pk)` serialises the name and the arguments, publishes a message, and returns immediately — the function body executes later, in a different process, possibly on a different machine, possibly after your web process has been replaced. Everything else follows from that separation. The worker has its own imports, so a task must be importable from the worker's environment; it has its own settings load, so anything read at import time is read there too; and it shares nothing in memory with the request that enqueued it, so state has to travel through the message or through the database. `@shared_task` versus `@app.task` is a smaller decision than it looks: `@shared_task` avoids importing the app object into every module, which keeps reusable apps portable, and in a Django project it is the default choice for that reason alone. Autodiscovery is where most "my task is not registered" confusion comes from — it looks in `tasks.py` specifically, so a task defined in `services.py` exists in your web process (which imported it) and does not exist in the worker (which did not).
What we're doing: The three files every Django + Celery project needs, wired so autodiscovery actually finds the tasks.
- 5
- Set before the app is created: the worker starts without `manage.py`, so nothing else points it at your settings module.
- 8–9
- The `CELERY` namespace means `CELERY_BROKER_URL` in Django settings becomes `broker_url` in Celery — one settings file rather than two.
- 10
- Autodiscovery looks for `tasks.py` specifically. This one line is why a task defined in `services.py` runs fine in a test and raises `NotRegistered` in production.
- 13–16
- Importing the app from `__init__.py` guarantees it exists before Django imports any application module, so `@shared_task` has something to register against.
- 23–26
- The task takes an id and reads the row itself — the worker has its own database connection and should see current state, not a snapshot from the message.
Why this works: These three files are almost entirely boilerplate, and every one of the common Celery-in-Django failures — settings not found, tasks not registered, app imported too late — is caused by one of them being wrong.
Defining a task outside `tasks.py`
Wrong
Better
What you see: `celery.exceptions.NotRegistered: accounts.services.send_welcome_email` in the worker log, while the web process enqueues happily and every test passes — because the test suite imported the module and the worker never did.
Why: `autodiscover_tasks()` imports a fixed filename from each installed app, so registration depends on where the function lives rather than on the decorator. The web process registers it as a side effect of importing `services.py` for something else; the worker imports only `tasks.py`. Keeping tasks in `tasks.py` as thin wrappers over ordinary functions elsewhere gives you discovery and keeps the logic testable without Celery.
- View calls send_email.delay(57) — returns in microseconds — nothing has run
- leads to Serialize name + args
- Serialize name + args — JSON by default, so arguments must be JSON-safe
- leads to Broker (Redis / RabbitMQ)
- Broker (Redis / RabbitMQ) — the message waits here — possibly across a deploy
- leads to Worker process
- Worker process — separate imports, separate settings, separate connections
- leads to Task registry lookup by name
- Task registry lookup by name — populated by autodiscover_tasks() from tasks.py only
- on error, leads to NotRegistered (name not found)
- leads to The function body finally runs (found)
- The function body finally runs
- leads to Result backend (optional) (if a backend is configured)
- NotRegistered — the task exists in the web process and not in the worker
- Result backend (optional) — only if configured — otherwise the return value is discarded
The three pieces, and where each one lives
Together
Remember: The app is created in `config/celery.py` and imported from `config/__init__.py` so it exists before any task is defined. `@shared_task` registers with the current app, which is what makes reusable apps work. `.delay()` publishes a message and returns — the body runs later, in a separate process with its own imports, settings and connections, so pass ids and read current state. And autodiscovery imports `tasks.py` specifically: a task defined anywhere else works in your web process and raises `NotRegistered` in the worker.
See also: brokers and result backends · retries backoff and scheduling · what belongs in a background job

