Celery Serialization Pitfalls and How task_always_eager Hides Bugs in CI
- Author
- Vishal Maurya
- Published on
- Reading time
- 4 min read
Overview
The test suite is green, CI is green, and the first real run in production fails with kombu.exceptions.EncodeError: Object of type User is not JSON serializable. Or worse, it runs but acts on stale data. The cause is often task_always_eager, which makes tests pass by skipping the parts of Celery that break in production.
1. What Eager Mode Actually Does
With task_always_eager = True, calling task.delay(...) runs the function immediately, in the same process, synchronously. There is no broker, no worker, no message, and by default no serialization round trip.
That means your tests never check that:
- the arguments can be serialized by the configured serializer,
- the task sees the database as committed state rather than your test's in-memory objects,
- the task name is registered and importable by a worker,
- the caller returns without waiting for the task,
- exceptions inside the task are handled as a worker would.
Eager mode is fine for exercising the logic of a task body. It is not evidence the task works through a queue.
2. The Serialization Trap
Since Celery 4 the default serializer is JSON. JSON handles strings, numbers, booleans, lists, dicts and None. It does not handle model instances, datetime, Decimal, UUID, sets or file handles the way you may expect.
# Works in eager mode, fails with a real broker
send_welcome_email.delay(user) # a model instance
In eager mode the function receives the very same Python object. With a worker, the message is encoded and the call fails with an EncodeError when the object is published.
Switching to pickle to make the error go away is a bad answer: it allows arbitrary code execution if an attacker can write to your broker, and it ties messages to exact class definitions. Keep JSON.
3. Pass Identifiers, Load Fresh State
Pass primary keys and primitives, and fetch the object inside the task.
from celery import shared_task
from django.contrib.auth import get_user_model
@shared_task
def send_welcome_email(user_id: int) -> None:
User = get_user_model()
try:
user = User.objects.get(pk=user_id)
except User.DoesNotExist:
return # deleted between enqueue and execution
deliver(user.email)
This also avoids acting on stale data: an object serialized at enqueue time may be outdated minutes later when the task runs. For dates, pass ISO strings and parse them, or pass the record ID and read the date from the database.
from datetime import datetime
@shared_task
def close_period(period_end_iso: str) -> None:
period_end = datetime.fromisoformat(period_end_iso)
4. Commit Timing Is Also Hidden
In eager mode a task enqueued inside a transaction runs immediately and sees the uncommitted data from the same connection. With a real worker, the task may start before the transaction commits and not find the row.
from django.db import transaction
def create_order(request):
order = Order.objects.create(...)
transaction.on_commit(lambda: process_order.delay(order.pk))
Using on_commit is the production-correct pattern. Note that Django's TestCase wraps tests in a transaction that is rolled back, so on_commit callbacks do not fire unless you use captureOnCommitCallbacks(execute=True) (available in recent Django versions) or TransactionTestCase.
def test_order_enqueues_task(self):
with self.captureOnCommitCallbacks(execute=True):
create_order(self.request)
5. Test Closer to Production
Split testing into layers.
- Unit tests call the task function directly and test the logic:
send_welcome_email(user.pk)orsend_welcome_email.apply(args=[user.pk]). - Enqueue tests mock
delayorapply_asyncand assert the call and its arguments are primitives. - A serialization check round-trips arguments through the serializer.
- An integration job runs a real worker against a real broker in CI for a few critical flows.
A simple serialization guard:
from kombu.utils.json import dumps, loads
def assert_serializable(*args, **kwargs):
loads(dumps({"args": args, "kwargs": kwargs}))
For a stronger check, Celery ships a pytest plugin (celery.contrib.pytest) with a celery_worker fixture that starts an in-process worker, which exercises serialization and task registration. Check the testing guide for your version's setup. You can also set task_eager_propagates = True so exceptions in eager tasks are raised in tests instead of being stored silently.
6. Set Settings Explicitly
Make serializer settings explicit so they are the same everywhere:
CELERY_TASK_SERIALIZER = "json"
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_RESULT_SERIALIZER = "json"
Avoid enabling eager mode globally for the whole suite. Turn it on only in tests that need it.
Conclusion
Eager mode checks that your function runs, not that your task survives the queue. Pass IDs and primitives, keep the JSON serializer, enqueue with transaction.on_commit, and add at least a few tests that exercise real serialization and a real worker.
If your Celery tests pass but production tasks keep failing, I can help restructure the tests and tasks. Contact me.