Python Celery Tasks: delay vs apply_async
Understand the difference between Celery's delay and apply_async methods, how to pass execution options, and when to use each for reliable async task execution.
Celery exposes two main ways to send a task to the broker: delay and apply_async. Both schedule the task for execution, but differ in flexibility and the options they accept. Understanding these differences helps you build reliable asynchronous workflows in Python.
The Two Ways to Call a Celery Task
Celery tasks are callable objects. Calling a task directly executes it synchronously in the current process. To execute asynchronously, you send a message to the broker using delay or apply_async. The broker then delivers the task to a worker process.
from celery import Celery app = Celery('tasks', broker='redis://localhost:6379/0') @app.task def add(x, y): return x + y
After defining add, you can call add.delay(2, 2) or add.apply_async((2, 2)). Both return an AsyncResult object that tracks the task's state.
The core difference is that apply_async accepts a wide range of execution options, while delay is a shortcut that only supports positional and keyword arguments for the task itself. delay internally calls apply_async with no extra options.
What delay Actually Does
delay is the simplest way to enqueue a task. It takes the same arguments as the task function and sends them to the broker immediately.
result = add.delay(2, 2) print(result.id) # UUID of the task
The implementation is essentially:
def delay(self, *args, **kwargs): return self.apply_async(args, kwargs)
Because delay passes no execution options, it is ideal for quick, fire-and-forget tasks where you do not need to control scheduling, routing, or task metadata. It keeps the call site clean and readable.
However, delay has a limitation: you cannot pass options like countdown, eta, expires, or queue. If you need any of those, you must use apply_async.
What apply_async Adds
apply_async is the full-featured method for task scheduling. It accepts the task arguments as a tuple and keyword arguments as a dictionary, plus a set of execution options.
result = add.apply_async((2, 2), countdown=10, queue='high_priority')
Commonly used options include:
countdown: delay task execution by N seconds.eta: schedule the task for a specific datetime.expires: set an expiry time after which the task is discarded.queue: route the task to a specific queue.priority: set the task priority (if supported by the broker).task_id: supply your own task ID instead of letting Celery generate one.
These options give you control over how and when tasks run, which is useful in production systems.
Passing Task Arguments and Execution Options
When using apply_async, separate the task arguments from the execution options. The first positional argument is a tuple of positional arguments for the task, and the second is a dictionary of keyword arguments. Options such as countdown are passed as keyword arguments to apply_async itself.
@app.task def send_email(to, subject): ... result = send_email.apply_async( ('a@b.com',), {'subject': 'Hello'}, countdown=5, )
If the task has no keyword arguments, omit the second positional argument:
result = add.apply_async((2, 2), countdown=5)
If you prefer to supply a task's arguments as keyword arguments, pass an empty tuple as the positional arguments:
result = send_email.apply_async((), {'to': 'a@b.com', 'subject': 'Hello'})
You can also use the args and kwargs keyword arguments explicitly:
result = add.apply_async(args=(2, 2), kwargs={}, countdown=5)
This is equivalent to the positional form and can improve readability when there are many options.
Handling Errors and Retries
The retry and retry_policy options on apply_async control how Celery retries sending the message if the broker connection fails; they do not retry the task when the worker raises an exception. For task-level retries, use self.retry() inside the task or configure autoretry_for on the task decorator in current Celery versions.
@app.task(bind=True, max_retries=3) def send_email(self, to, subject): try: # send the email ... except Exception as exc: raise self.retry(exc=exc, countdown=60)
In this example, bind=True gives the task access to self, max_retries limits the number of retry attempts, and self.retry() schedules the failing task again after a 60-second countdown. This is the mechanism that actually retries a failed task.
Production Considerations
Choosing between delay and apply_async has operational implications. delay is convenient but hides execution options. If you later need to add a countdown or a queue, you must change the call site to apply_async. That is straightforward, but easy to miss if delay is scattered across the codebase.
In production, you often want to set a default queue or a default time limit. You can do this in the task decorator itself:
@app.task(queue='default', time_limit=30) def add(x, y): return x + y
Then add.delay(2, 2) uses the queue defined in the decorator. But if you need per-call overrides, apply_async is the only way.
Another consideration is observability. With apply_async, you can pass task_id explicitly, which is useful for correlating tasks with external systems:
import uuid task_id = str(uuid.uuid4()) result = add.apply_async((2, 2), task_id=task_id)
This is not possible with delay because it generates a random ID internally.
Finally, be aware that apply_async is more verbose. For simple tasks where none of the extra options are needed, delay keeps the code readable. The rule of thumb: use delay for simple fire-and-forget calls, and apply_async when you need to control scheduling, routing, or task IDs.
Understanding the difference between these two methods prevents subtle bugs. For example, forgetting that delay does not accept a countdown argument will raise a TypeError at runtime. Knowing the API boundaries helps you write correct, maintainable Celery code from the start.