Softotic
Back to insights
27 August 202618 min readDjangoCeleryPythonBackground TasksBackend Architecture

Django 6.0 Tasks vs Celery: When Is the Built-In Task Framework Enough?

Django 6.0 added a built-in Tasks framework, but it does not ship a production worker. Compare Django Tasks and Celery for retries, scheduling, workflows, queues, reliability, and migration.

Django 6.0 Tasks vs Celery: When Is the Built-In Task Framework Enough?

Django 6.0 finally gives Django applications a standard API for background tasks, but it does not make Celery obsolete.

The new __INLINE_CODE_0__ framework defines how tasks are declared, validated, queued and tracked. Django deliberately does not ship a production worker engine or durable production queue; its built-in __INLINE_CODE_1__ and __INLINE_CODE_2__ are for development and testing. A production deployment still needs external task infrastructure.

That changes the architecture decision. You no longer have to choose between “Django alone” and “Celery” as if they were equivalent products. The real choice is:

Do you need Django's standard task contract with a suitable production backend, or do you need Celery's mature execution platform, retries, scheduling, routing and workflow primitives?

This guide compares the two without the “Celery killer” headline and gives a practical decision framework for new and existing Django systems.

What did Django 6.0 actually add?

Django 6.0 introduced a built-in Tasks framework through __INLINE_CODE_0__.

The Django 6.0 release notes describe it as infrastructure for running work outside the HTTP request-response cycle. Django provides task definition, validation, queuing and result handling, while execution remains the responsibility of external worker infrastructure.

A basic task looks like:

python
from django.core.mail import send_mail
from django.tasks import task


@task
def send_welcome_email(email: str) -> int:
    return send_mail(
        subject="Welcome",
        message="Thanks for joining.",
        from_email=None,
        recipient_list=[email],
    )

You enqueue it with:

python
send_welcome_email.enqueue(email="user@example.com")

That API is valuable because application code can depend on Django's task abstraction rather than directly coupling every task definition to one queue product.

But there is an important boundary.

Django's own Tasks framework documentation says the framework does not provide the worker mechanism that executes production tasks.

A useful mental model is:

text
Django Tasks
= task contract + queue interface + task/result model

Production task backend
= durable queue + execution mechanism

Worker infrastructure
= processes/services that actually run the work

Django standardised the first layer. It did not silently bundle the other two.

Does Django 6.0 include a production task queue?

No.

This is the most important fact to understand before replacing an existing Celery deployment.

Django currently includes two built-in task backends:

BackendWhat it doesProduction use?
__INLINE_CODE_0__Executes the task immediately in the current processNo, primarily development/testing
__INLINE_CODE_0__Stores task results for inspection but does not execute the taskNo, development/testing

The Django task reference explicitly says Django includes only development and testing backends and points production applications to external backends.

So this:

python
TASKS = {
    "default": {
        "BACKEND": "django.tasks.backends.immediate.ImmediateBackend",
    }
}

does not make an HTTP request asynchronous.

If a view enqueues a 30-second task through __INLINE_CODE_0__, that task runs immediately in the same process.

For production, choose a backend that provides the durability and worker behaviour your application actually requires.

Django Tasks and Celery are not the same layer

This is why “Django Tasks vs Celery” can be a misleading comparison.

Celery is a distributed task execution system. It includes mature concepts around workers, brokers, retry policies, task routing, concurrency, periodic scheduling, workflow composition, monitoring and worker lifecycle.

The current Celery 5.6 user guide documents those operational capabilities.

Django Tasks is intentionally a narrower framework contract.

Think of the architecture this way:

text
Your Django application
        ↓
Task API
        ↓
Queue/backend
        ↓
Workers
        ↓
Execution policy
        ↓
Monitoring / retries / scheduling

Django 6.0 now owns a standard version of the Task API layer.

Celery can cover several layers below and around it.

That means the right design can even involve Django's Tasks API with a production backend that uses another execution system underneath. The abstraction and the worker technology do not have to be the same thing.

The decision table: Django Tasks or Celery?

RequirementDjango 6 Tasks APICelery
Standard Django-native task definitionExcellentCelery-specific task API
Built-in production workerNoYes
Built-in production broker/queueNoUses supported external brokers
Simple background email/file processingGood with a production backendGood
Queue namesSupported by task contract/backend capabilityMature routing/queue controls
Task prioritySupported when backend supports itSupported depending on broker/transport
Deferred execution__INLINE_CODE_0__ when backend supports itMature countdown/ETA scheduling
Automatic retry/backoffBackend/application responsibilityMature __INLINE_CODE_0__ and __INLINE_CODE_1__
Periodic schedulingNot a complete built-in schedulerCelery Beat
Chains/groups/chordsNot a core Django Tasks featureBuilt-in Canvas primitives
Distributed worker operationsBackend-dependentMature worker model
Time/rate limitsBackend-dependentMature worker/task controls
Monitoring/remote worker controlBackend-dependentMature ecosystem and worker controls
Portability between task backendsCore design advantageMore Celery-coupled
Existing large Celery deploymentNo inherent reason to migrateKeep it unless there is a business reason

The key phrase in the Django column is often “backend-dependent.”

Django's task contract exposes capabilities such as queue names, priority and deferred execution, but a backend can advertise whether it actually supports them.

When Django Tasks is a good fit

Django's new API is attractive when the problem is fundamentally simple:

text
HTTP request
→ enqueue independent work
→ run it outside the request
→ record result/status if needed

Examples include sending a transactional email, resizing an uploaded image, generating a PDF, calling a non-critical external API, exporting a report, sending a notification, or performing a single independent post-processing step.

For these tasks, adopting a large workflow system can be unnecessary if a reliable production backend meets the application's requirements.

The strongest reason to use Django Tasks is not “fewer dependencies”

It is a stable application-facing contract.

A reusable Django app can define:

python
from django.tasks import task


@task
def rebuild_search_document(object_id: int):
    ...

without forcing every consuming project to adopt one specific task library at the task-definition layer.

That is strategically useful for the Django ecosystem.

When Celery is still the stronger choice

Celery remains a strong fit when the application needs execution features that go well beyond enqueueing an independent function.

1. Retries are part of the business requirement

Suppose a task calls a flaky external API.

The current Celery task documentation supports explicit retries and automatic retries for selected exceptions:

python
from requests import RequestException


@app.task(
    autoretry_for=(RequestException,),
    retry_backoff=True,
    max_retries=5,
)
def sync_customer(customer_id):
    ...

Celery supports exponential backoff and jitter controls.

If your production requirements say:

text
retry 5 times
with exponential backoff
capture retry state
alert after exhaustion

then “the task can be enqueued” is only the beginning.

You need to evaluate whether the chosen Django Tasks backend gives you the reliability semantics you need or whether Celery already solves them cleanly.

2. You need task workflows

Celery's Canvas workflow primitives include __INLINE_CODE_0__, __INLINE_CODE_1__, __INLINE_CODE_2__, __INLINE_CODE_3__ and __INLINE_CODE_4__.

That enables structures such as:

text
Import file
    ↓
Parse rows
    ↓
Process batches in parallel
    ↓
Wait for all batches
    ↓
Generate summary
    ↓
Notify user

If your application needs dependency graphs rather than independent background jobs, Celery's workflow model is materially more mature.

3. You need operational control over workers

Celery's worker documentation includes controls for concurrency, queues, worker inspection, autoscaling, rate limits, time limits and process lifecycle.

That matters when background work is a production subsystem rather than a convenience feature.

For example:

text
emails queue
→ many lightweight workers

video queue
→ low-concurrency CPU-heavy workers

payments queue
→ isolated high-priority workers

imports queue
→ long-running workers

Celery has mature operational patterns for this type of workload.

4. You already operate Celery successfully

If Celery is stable, monitored and understood by the team, Django 6.0 does not create an automatic migration requirement.

Replacing working infrastructure has a cost.

A migration only makes sense if it meaningfully improves developer ergonomics, reliability, operational cost, portability or maintenance burden.

“Django now has __INLINE_CODE_0__” is not, on its own, a business case.

What does __INLINE_CODE_0__ mean in Django Tasks?

Django's task API includes a __INLINE_CODE_0__ attribute, allowing a task to express the earliest time it should run.

For example:

python
from datetime import timedelta


cleanup_task.using(
    run_after=timedelta(minutes=10)
).enqueue(record_id=123)

But the Django task reference says this requires a backend whose __INLINE_CODE_0__ capability is __INLINE_CODE_1__.

That distinction is important:

Django defines the contract for deferred execution; the backend determines whether and how it can honour that contract.

Do not assume __INLINE_CODE_0__ is a replacement for a mature recurring scheduler.

What about recurring tasks and cron jobs?

A recurring job such as:

text
Every day at 02:00
→ reconcile invoices

is different from:

text
Run this task no earlier than 10 minutes from now

Celery has a dedicated scheduler, Celery Beat, for recurring tasks.

Django Tasks does not by itself provide an equivalent complete periodic scheduling service.

For simple systems, recurring work can be triggered through cron, systemd timers, Kubernetes CronJobs, cloud schedulers or platform-native scheduled jobs, and then enqueue a Django task.

That can be simpler than operating Celery Beat.

But if the application already needs centralised dynamic schedules, admin-managed periodic jobs and distributed Celery workers, keeping scheduling in the same task platform may be preferable.

The transaction race that both approaches must handle

Background tasks often interact with database writes.

This can fail:

python
with transaction.atomic():
    order = Order.objects.create(...)
    process_order.enqueue(order_id=order.id)

A fast worker may receive the task before the database transaction commits.

The task then queries the order from a separate connection and cannot see it yet.

Django's Tasks documentation explicitly recommends enqueueing through __INLINE_CODE_0__ when a task depends on data created inside a transaction.

For example:

python
from functools import partial
from django.db import transaction


with transaction.atomic():
    order = Order.objects.create(...)

    transaction.on_commit(
        partial(
            process_order.enqueue,
            order_id=order.id,
        )
    )

This is not a Django-Tasks-only problem. It is a general background-processing problem.

Moving from Celery to a new API does not remove transaction boundaries.

Pass IDs, not model instances

Django Tasks serialises task arguments through a JSON round trip.

Prefer:

python
process_order.enqueue(order_id=order.id)

over:

python
process_order.enqueue(order=order)

Passing an identifier creates a cleaner distributed-system boundary. The worker can load current database state when it executes.

This reduces stale serialised state, coupling to Python object internals, serializer surprises and oversized task messages.

The same principle is valuable in Celery even though Celery supports a broader serializer ecosystem.

Reliability is a backend question, not a decorator question

This is the biggest architecture trap in the current “Django vs Celery” conversation.

Two projects can contain identical code:

python
@task
def generate_report(report_id):
    ...

but have completely different production reliability.

Project A:

text
non-durable queue
one worker
no retry strategy
no dead-letter handling
no monitoring

Project B:

text
durable queue
multiple workers
retry policy
visibility/lease semantics
monitoring
failure recovery

The decorator looks the same.

The system is not.

When evaluating a production Django Tasks backend, ask:

  1. Is the queue durable?
  2. What happens if a worker crashes after claiming a task?
  3. Is execution at-most-once or at-least-once?
  4. How are retries implemented?
  5. Can retries use backoff?
  6. Are failed tasks retained?
  7. Can a poison task be quarantined?
  8. How are workers scaled?
  9. How are queues isolated?
  10. What monitoring is available?
  11. How are results retained and expired?
  12. Can tasks be deferred?
  13. How are graceful shutdowns handled?
  14. What happens during backend outages?

That checklist is more meaningful than asking whether a library has a __INLINE_CODE_0__ decorator.

Background tasks should usually be idempotent

Neither Django Tasks nor Celery can make arbitrary side effects magically exactly-once.

A worker can fail after completing an external action but before marking a task successful.

For example:

text
Task starts
→ creates ERP record
→ worker crashes
→ task is retried
→ ERP record is created twice

Design important operations so repeated execution is safe.

Possible strategies include:

  • database unique constraints
  • external API idempotency keys
  • state-machine transitions
  • outbox patterns
  • business-level “already processed” keys
  • reconciliation before repeating side effects

The requirement is independent of which task framework wins the comparison.

A practical architecture for a modest Django SaaS

For a SaaS with emails, imports and report generation:

text
Django web app
      ↓
django.tasks API
      ↓
Production task backend
      ↓
Durable queue
      ↓
Worker process
      ↓
Application services
      ↓
PostgreSQL / external APIs

Use queue separation where useful:

text
default
emails
imports
reports

Keep task functions thin:

python
@task(queue_name="reports")
def generate_report(report_id: int):
    ReportService.generate(report_id)

The task should orchestrate an application service rather than contain hundreds of lines of business logic.

That keeps business logic testable without a queue.

A practical architecture when Celery is justified

For a system with complex background processing:

text
Django/API
    ↓
Celery producer
    ↓
Redis/RabbitMQ or supported broker
    ↓
Specialised worker queues
    ↓
Retry / routing / Canvas workflows
    ↓
Result and monitoring systems

Typical triggers include:

  • multi-stage data pipelines
  • external APIs requiring controlled retries
  • fan-out/fan-in processing
  • high task volume
  • long-running jobs
  • task routing by workload type
  • dynamic recurring schedules
  • mature Celery monitoring already in place

Celery's complexity has a cost, but complexity can also be a feature when the workload genuinely requires it.

Can you start with Django Tasks and move to Celery later?

Potentially, and this is one of the strongest reasons to adopt Django's standard task API for new simple jobs.

If application code depends primarily on the Django Tasks contract, a production backend can be changed without rewriting every call site.

But portability has limits.

Once code depends on Celery-specific features such as Canvas chains/chords, Celery retries, broker-specific routing, custom Celery Task classes, signals or Beat integration, the application is coupled to those capabilities.

That is not necessarily bad.

A useful architecture separates:

text
portable simple tasks

from:

text
advanced workflow jobs

Do not force complex workflow requirements through the lowest common denominator merely to preserve theoretical portability.

Should an existing Celery project migrate to Django Tasks?

Use a migration gate.

Consider migration when

  • Celery is used only for simple independent jobs
  • the team finds Celery operationally expensive
  • a production Django Tasks backend meets reliability requirements
  • no Celery-specific workflows are used
  • retry requirements are modest or handled elsewhere
  • scheduling is simple
  • the migration produces a measurable benefit

Keep Celery when

  • Celery is stable and already monitored
  • chains/groups/chords are heavily used
  • retries/backoff are central
  • Celery Beat manages important schedules
  • queues/routing are operationally important
  • worker autoscaling/time limits/rate limits matter
  • the team has mature Celery runbooks
  • there is no measurable benefit to replacing it

The right answer for a stable production system is often:

Do nothing yet.

A new framework feature should reduce risk or cost before it earns a migration.

What should a new Django 6 project choose?

Use this decision sequence.

Step 1: list the actual jobs

For example:

text
send signup email
generate invoice PDF
sync CRM
resize images
run nightly reconciliation
process 5 GB import
fan out 20,000 jobs
retry partner API for 24 hours

Step 2: classify every job

Ask about:

  • expected duration
  • frequency
  • acceptable delay
  • retry requirement
  • idempotency strategy
  • scheduling requirement
  • concurrency
  • priority
  • failure visibility
  • dependency on other jobs

Step 3: choose the smallest production system that satisfies the requirements

If jobs are independent and simple, Django Tasks plus a reliable backend can be a clean starting point.

If the requirements already describe orchestration, distributed routing, rich retries and worker controls, choosing Celery from day one can be cheaper than re-platforming later.

Step 4: separate business logic from the task framework

Good:

python
def issue_monthly_statement(account_id: int):
    ...

and a thin Django Tasks or Celery wrapper around it.

This keeps business logic testable without queue infrastructure.

Common mistakes with Django 6 Tasks

Assuming the default backend is asynchronous

__INLINE_CODE_0__ executes immediately. It is not a production background worker.

Assuming Django ships a durable worker

It does not. Production execution requires external backend/infrastructure.

Passing model instances

Pass stable, JSON-safe values such as IDs.

Enqueueing before a transaction commits

Use __INLINE_CODE_0__ when the worker depends on newly committed state.

Choosing a backend only because installation is simple

Evaluate durability, failure recovery and operations.

Migrating from Celery because of a headline

Compare the capabilities your system actually uses.

Common mistakes with Celery

Adding Celery for one trivial task without evaluating simpler infrastructure

Every broker, worker and scheduler adds operational surface area.

Treating retries as permission to write non-idempotent tasks

Retries make idempotency more important, not less.

Mixing every workload into one queue

Heavy imports can starve urgent jobs when routing/concurrency is not designed.

Hiding business logic inside task classes

Keep application services independently testable.

Operating Celery without monitoring

Background work is production work. Failures need visibility.

A concise recommendation matrix

Your situationRecommended starting point
New Django 6 app with a few independent jobsDjango Tasks + production-ready backend
Transactional email, small reports, simple post-processingDjango Tasks + suitable backend
Need retries/backoff as a core featureCelery or a backend with equally explicit semantics
Need chains/groups/chordsCelery
Need mature periodic scheduling in the task stackCelery + Beat
Need complex routing and worker operationsCelery
Existing healthy Celery systemKeep Celery unless migration has measurable value
Reusable Django package exposing simple background jobsDjango Tasks API is attractive
Unsure about future scaleStart from requirements and keep business logic framework-neutral

Neither “built in” nor “battle-tested” should decide the architecture by itself.

FAQs

Does Django 6.0 replace Celery?

No. Django 6.0 introduces a standard Tasks API, but Django's documentation says it does not provide a production worker mechanism. Celery remains a mature distributed task execution system with retries, workers, scheduling, routing and workflow primitives.

Can Django 6.0 run background tasks without Celery?

Yes, but production execution still needs an external production-capable task backend and worker infrastructure. Django's built-in __INLINE_CODE_0__ and __INLINE_CODE_1__ are intended for development and testing rather than production asynchronous processing.

Does Django Tasks support retries?

Django defines the common task/backend contract, but retry behaviour is not a built-in Celery-style retry system in the core API. Evaluate the production backend's retry and failure semantics. Celery provides explicit __INLINE_CODE_0__ and __INLINE_CODE_1__ features with backoff controls.

Does Django Tasks support scheduled jobs?

The task contract includes __INLINE_CODE_0__ for deferred execution when the backend supports it. That is not the same as a complete recurring scheduler such as Celery Beat. Recurring jobs can also be triggered by cron, Kubernetes CronJobs or cloud schedulers.

Should I migrate an existing Celery project to Django Tasks?

Not automatically. If Celery is stable and the project uses retries, routing, Beat or Canvas workflows, migration may remove useful capabilities. Consider migration only when a simpler production backend satisfies actual requirements and produces measurable operational or development benefits.

What is the biggest advantage of Django's built-in Tasks framework?

It gives Django applications and reusable packages a standard task-definition and enqueueing contract. That can reduce application-level coupling to a particular task implementation for simple background jobs.

Conclusion

Django 6.0 changes background-task architecture, but not in the way the “Celery killer” headline suggests.

Django now gives the ecosystem a common contract:

text
define
→ validate
→ enqueue
→ track

Production infrastructure still has to provide:

text
durability
→ workers
→ execution
→ retries
→ failure recovery
→ operations

For simple independent jobs, Django Tasks plus a reliable production backend can be a clean and intentionally smaller system.

For applications that need rich retries, recurring schedules, task graphs, routing and mature worker operations, Celery remains a strong choice.

The best architecture is the smallest system that can meet your background-work reliability requirements without making future operations fragile.

If you are designing a Django SaaS or modernising an existing backend, Softotic's custom software development service can help structure the application and worker architecture, while web application development can cover the Django/API and user-facing application layer.

Sources and references