Fix Celery Tasks Stuck in PENDING

Fix Celery tasks that stay PENDING in Django: missing result backends, workers not consuming queues, routing mismatches, unregistered tasks, and commit timing.

Problem statement

You call task.delay() in Django, save the task ID, and when you check AsyncResult(task_id).status, it always says PENDING. The task never seems to finish, or it clearly finished (emails were sent) but the status still reads PENDING.

PENDING is Celery's default state for any task ID it has no information about. The documentation is explicit: a task ID that was never sent, a task still waiting in the queue, and a task whose result was never stored all report PENDING. That makes it an ambiguous symptom with several unrelated causes:

  • no result backend is configured, so states are never stored
  • workers are not running, not connected to the same broker, or not listening to the queue the task was sent to
  • the worker does not have the task registered and discards the message
  • the task was sent before the database transaction committed, and fails or does nothing silently
  • the result expired from the backend

This guide separates those cases step by step.

What PENDING actually meansTask sentor never sentQueuematching worker?Workertask registered?Result backendstores state?PENDING is the default for any task ID Celery has no record of.

Quick answer

  1. Check whether a result backend is configured (CELERY_RESULT_BACKEND); without one, status is always PENDING.
  2. Enable CELERY_TASK_TRACK_STARTED = True so running tasks report STARTED.
  3. Confirm workers are running and connected: celery -A config inspect ping.
  4. Confirm the queue the task was routed to matches the queues workers consume: celery -A config inspect active_queues.
  5. Check worker logs for Received task and for Received unregistered task.
  6. Check the broker for a growing queue length.
  7. Send tasks with transaction.on_commit() when they depend on data written in the same request.

Step-by-step solution

1. Check for a result backend

python manage.py shell -c "from config.celery import app; print(app.conf.result_backend)"
PENDING can just mean unknownNo result backend✕state is never stored✕AsyncResult reports PENDING forever✕even after the task succeededResult backend set✓SUCCESS or FAILURE recordedresults expire after a set timeSTARTED needs track_startedCheck the worker log before assuming the task never randjango-deployment.com
Celery returns PENDING for any task ID it knows nothing about, even a made-up one.

If this prints None, Celery does not store task states, and AsyncResult.status will always be PENDING, even for tasks that succeeded. Tasks may be running perfectly.

Configure one only if you actually need results or states:

CELERY_RESULT_BACKEND = "redis://127.0.0.1:6379/2"
CELERY_RESULT_EXPIRES = 60 * 60 * 24
CELERY_TASK_TRACK_STARTED = True

Or with django-celery-results, which stores results in the Django database:

INSTALLED_APPS += ["django_celery_results"]
CELERY_RESULT_BACKEND = "django-db"

Many applications do not need result storage at all; they record progress in their own models (for example a ReportJob.status field), which is easier to query and does not expire unexpectedly.

2. Make STARTED visible

By default, a task goes from PENDING directly to SUCCESS or FAILURE. With CELERY_TASK_TRACK_STARTED = True, the worker reports STARTED when it begins executing. This lets you tell "waiting in the queue" from "running a long time".

reading task statesMeansPENDING!unknown: queued, lost, or not trackedSTARTEDa worker began it (if tracking is on)RETRYscheduled to run againSUCCESS / FAILURE✓finished, result storedPENDING is the default answer, not a real statedjango-deployment.com
CELERY_TASK_TRACK_STARTED = True makes long tasks show progress.

3. Are workers alive and connected?

celery -A config inspect ping
celery -A config status

No replies means no worker is connected to the broker your Django process uses. Check:

  • the worker service is running: systemctl status celery-worker or docker compose ps worker
  • the worker's broker URL matches the web process's broker URL (same host, port, database number, and password)

Print both from their environments:

python manage.py shell -c "from config.celery import app; print(app.conf.broker_url)"
sudo systemctl show celery-worker -p Environment

A worker connected to redis://127.0.0.1:6379/0 will never see tasks the web process sends to redis://127.0.0.1:6379/1.

4. Do queues match?

If tasks are routed to a named queue, a worker must consume that queue:

CELERY_TASK_ROUTES = {
    "reports.tasks.*": {"queue": "reports"},
}
celery -A config inspect active_queues

A worker started with -Q default does not consume reports. Start a worker for it, or add it:

celery -A config worker -Q default,reports --loglevel=INFO

Look at queue lengths in the broker. For Redis, each queue is a list named after the queue:

redis-cli -n 0 LLEN celery
redis-cli -n 0 LLEN reports

A growing list means messages are arriving but nothing consumes them. Use the database number from your broker URL.

5. Is the task registered on the worker?

When a worker receives a message for a task name it does not know, it logs an error and discards the message:

[ERROR/MainProcess] Received unregistered task of type 'reports.tasks.build_report'.
The message has been ignored and discarded.

Causes:

  • the worker runs older code than the web process (deploys that restart web but not workers)
  • the task module is not imported (not in an app listed in INSTALLED_APPS, or autodiscover_tasks() missing)
  • the task was renamed or moved, and old messages still use the previous name

List registered tasks:

celery -A config inspect registered

Restart workers on every deploy that changes task code, and restart them before the web process starts sending new task names.

6. Tasks sent before the transaction commits

A common Django-specific cause of "task never did anything":

def create_order(request):
    with transaction.atomic():
        order = Order.objects.create(...)
        send_confirmation.delay(order.pk)   # sent before commit
the task outran the transactionOrder createdinside atomic()task.delay(order.pk)sent immediatelyWorker runs itrow not committedDoesNotExisttask fails or retriesFix: on_commitsend after committransaction.on_commit(lambda: task.delay(pk)) closes the racedjango-deployment.com
Celery 5.4 and later also offers delay_on_commit() for Django projects.

The worker can pick up the message before the transaction commits, run Order.objects.get(pk=...), and fail with DoesNotExist, or find stale data. Send after commit:

def create_order(request):
    with transaction.atomic():
        order = Order.objects.create(...)
        transaction.on_commit(lambda: send_confirmation.delay(order.pk))

Celery 5.4 also added delay_on_commit() for Django projects, which does the same thing.

7. Check worker logs for the task ID

sudo journalctl -u celery-worker --since "1 hour ago" | grep <task-id>
  • Received task: reports.tasks.build_report[<task-id>] then succeeded: the task ran; if status is still PENDING, the result backend is missing, different, or the result expired.
  • Received task without completion: the task is still running or the worker died mid-task.
  • Nothing: the message never reached this worker (queue or broker mismatch).

8. Results that expire

Results in Redis expire after result_expires (one day by default). Checking the status of an old task ID returns PENDING again. Store long-lived outcomes in your own models.

9. Prefetch and long tasks

A worker reserves messages in advance (prefetch). If one worker process is busy with a long task and has reserved other messages, those wait even though other workers are idle, which looks like PENDING for a long time. For long tasks:

CELERY_WORKER_PREFETCH_MULTIPLIER = 1
CELERY_TASK_ACKS_LATE = True
no task reserved behind a long oneCELERY_WORKER_PREFETCH_MULTIPLIER =1CELERY_TASK_ACKS_LATE =TrueCELERY_TASK_ROUTES = {"reports.*": {"queue":"long"}}1231Each process reserves only the task it runs.2Ack after finishing; tasks must be idempotent.3Long tasks on their own queue and workers.Prefetched tasks look PENDING while they wait in a busy workerdjango-deployment.com
With the default prefetch of 4, short tasks can sit behind a long one for minutes.

With acks_late, a message is acknowledged after the task completes, so the worker only reserves what it can start soon. Make those tasks idempotent, because a worker crash causes redelivery.

10. Verify

from config.celery import app
from reports.tasks import ping_task

result = ping_task.delay()
print(result.id)
print(result.get(timeout=30))
print(result.status)

Where ping_task is a trivial task that returns a value. result.get() requires a result backend.

Verification check:

  • inspect ping returns replies from every expected worker
  • active_queues covers every queue that tasks are routed to
  • inspect registered includes the task names you send
  • queue lengths in the broker stay near zero under normal load
  • a test task moves through STARTED to SUCCESS

Explanation

Celery separates sending (web process to broker), execution (worker from broker), and result storage (worker to backend). PENDING only means the backend has no record, which can be caused by a failure at any of the three stages or by having no backend at all. Checking each stage independently (worker connectivity, queue consumption, task registration, result storage) narrows the problem quickly.

Edge cases / notes

CELERY_TASK_ALWAYS_EAGER left enabled

With task_always_eager, tasks run inline in the web process and are never sent to the broker. It is meant for tests. If it is set in production settings, workers stay idle.

Visibility timeout with Redis

With the Redis broker, unacknowledged messages are redelivered after the visibility timeout (one hour by default). Tasks with ETAs or countdowns longer than that can execute more than once.

Monitoring

Flower or broker metrics show queue lengths, worker status, and task rates. Alert on queue length growth rather than on individual task states.

Tracking job status without relying on Celery results

For user-facing progress (imports, exports, report generation), store status in your own model instead of polling AsyncResult:

# reports/models.py
from django.conf import settings
from django.db import models


class ReportJob(models.Model):
    class Status(models.TextChoices):
        QUEUED = "queued"
        RUNNING = "running"
        DONE = "done"
        FAILED = "failed"

    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    status = models.CharField(max_length=10, choices=Status.choices, default=Status.QUEUED)
    task_id = models.CharField(max_length=255, blank=True)
    error = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
# reports/tasks.py
from celery import shared_task

from .models import ReportJob


@shared_task(bind=True)
def build_report(self, job_id):
    ReportJob.objects.filter(pk=job_id).update(status=ReportJob.Status.RUNNING, task_id=self.request.id)
    try:
        generate_report(job_id)
    except Exception as exc:
        ReportJob.objects.filter(pk=job_id).update(status=ReportJob.Status.FAILED, error=str(exc)[:2000])
        raise
    ReportJob.objects.filter(pk=job_id).update(status=ReportJob.Status.DONE)

This has several advantages over Celery result states: status survives result expiry, it is queryable with the ORM (for example "jobs queued for more than ten minutes"), and a job that stays queued clearly indicates the task never started.

Detecting stuck queues automatically

A scheduled check that alerts when queued jobs are old catches worker or routing problems without anyone polling task IDs:

from datetime import timedelta

from django.utils import timezone

from reports.models import ReportJob


def stale_queued_jobs(minutes=15):
    cutoff = timezone.now() - timedelta(minutes=minutes)
    return ReportJob.objects.filter(status=ReportJob.Status.QUEUED, created_at__lt=cutoff).count()

Expose the count to monitoring or run it from a management command on a timer and alert when it is above zero.

Common mistakes

  • Treating PENDING as "failed" without checking whether a result backend exists.
  • Restarting web processes on deploy but not Celery workers.
  • Routing tasks to queues nobody consumes.
  • Calling .delay() inside transaction.atomic() for tasks that read the new rows.

Next steps

FAQ

Why is my Celery task always PENDING even though it ran?

Most often there is no result backend configured, so the task's state is never stored. PENDING is the default for any task ID Celery has no record of.

How do I know if a Celery worker received my task?

Search the worker logs for the task ID. A Received task line shows the worker got the message; a succeeded or exception line shows the outcome.

Why do Celery tasks stay PENDING when workers are running?

Workers may consume different queues or connect to a different broker database than the web process, or the task may be unregistered on the worker and discarded.

Should I call delay() inside transaction.atomic()?

Use transaction.on_commit(lambda: task.delay(...)) or delay_on_commit() instead, so the worker only runs after the data it needs is committed.

Keep going