skip to content

Why does Django's Tasks framework reject a model instance passed to enqueue(), and what should a thumbnail task receive instead?

level: seniorimportance: should knowfreq 30%

answer

  1. arguments cross a process boundary
  2. a JSON round-trip
  3. tuples come back as lists
  4. re-fetch inside the task

basics

~20 s

Task arguments and return values go through a JSON round-trip so another process can run the task, so a model instance or datetime raises TypeError. Pass the primary key and re-fetch the row inside the task, handling a deleted or uncommitted row.

solid answer

~40 s

`enqueue()` normalises arguments to JSON-compatible types when it builds the `TaskResult`, so a `Photo` instance, a `datetime` or a `Decimal` raises `TypeError` right there. Even values that serialise can change type: a tuple comes back as a list, which breaks code using it as a dict key. Passing the primary key is also the right design: the worker loads the current row instead of a snapshot taken at enqueue time, and the argument stays small. The task must then cope with `Photo.DoesNotExist` when the row was deleted, and the enqueue should happen only after the creating transaction commits, otherwise a worker can look for a row it cannot see yet.

code

python · 12 lines
python
from functools import partial

from django.db import transaction

from .tasks import make_thumbnail


def save_photo(form):
    with transaction.atomic():
        photo = form.save()
        transaction.on_commit(partial(make_thumbnail.enqueue, photo.pk))
    return photo

go deeper

for a junior

Remember that task arguments must be JSON-friendly, so pass a primary key rather than the object.

for a middle

Explain the JSON round-trip on enqueue, the TypeError for instances and datetimes, and the tuple-to-list change.

for a senior

Design tasks that re-fetch by id, tolerate deleted rows and are enqueued after commit, and explain why ImmediateBackend hides these bugs.

for a principal

Treat task signatures as a versioned contract between deploys and require id-only arguments as a team convention.

## Why arguments are JSON In the **Tasks framework**, the code that enqueues a task and the code that runs it are expected to be in **different processes**: a web process enqueues, a worker process executes, possibly minutes later on another machine. So the call must be stored as data. Django's contract is that `Task.enqueue(*args, **kwargs)` passes arguments to the function **after a `json.dumps`/`json.loads` round-trip**, and return values are serialised the same way. When `enqueue()` builds the `TaskResult`, it runs the arguments through a normaliser that accepts only strings, numbers, booleans, `None`, lists (sequences) and dicts (mappings), and decodes `bytes` as UTF-8. Anything else raises `TypeError` **at enqueue time**, before the backend sees it. | Argument | Result | |---|---| | `photo.pk` (an int) | Accepted | | `str(photo.uuid)` | Accepted | | `photo` (a model instance) | `TypeError` | | `timezone.now()` | `TypeError` | | `Decimal("9.99")` | `TypeError` | | `(1, 2, 3)` | Accepted, but arrives as `[1, 2, 3]` | | `{1: "a"}` | Accepted; a backend that stores JSON hands the keys back as strings | The tuple case is the subtle one. The Django docs show a task that uses its argument as a dictionary key: with a tuple it works when called directly, but after the round-trip it receives a list, raises `TypeError: unhashable type`, and the result is `FAILED`. ## Why primary keys are the right design anyway Even if a backend could pickle a model instance, passing ids is better: 1. **Freshness.** The worker may run minutes later. An instance captured at enqueue time is a snapshot; fields may have changed. Re-fetching gives current data. 2. **Size.** A queue message with an id is tiny; a serialised object with file fields is not. 3. **Deletion.** If the user deleted the photo, `Photo.objects.get(pk=...)` raises `DoesNotExist`, which the task can treat as "nothing to do" instead of processing a ghost. ```python from django.tasks import task from .imaging import render_thumbnail from .models import Photo @task def make_thumbnail(photo_id): try: photo = Photo.objects.get(pk=photo_id) except Photo.DoesNotExist: return None # deleted before the worker got to it render_thumbnail(photo) return photo.pk ``` Convert other rich values explicitly: `when.isoformat()` in the caller and `datetime.fromisoformat()` in the task; `str(amount)` and `Decimal(...)`. ## The row may not exist yet A worker uses its own database connection. If a view creates the `Photo` inside a transaction (an `atomic` block, or `ATOMIC_REQUESTS`) and enqueues before the transaction commits, a fast worker can run `Photo.objects.get()` and not find the row. The Django docs recommend enqueueing from `transaction.on_commit()`, binding the arguments with `functools.partial`: ```python from functools import partial from django.db import transaction transaction.on_commit(partial(make_thumbnail.enqueue, photo.pk)) ``` With the default `ImmediateBackend` this bug is invisible, because the task runs in the same thread and connection as the view. It appears only once a real worker backend is configured — a classic "works in dev, flaky in production" pattern. ## Return values follow the same rule `TaskResult.return_value` holds the JSON-normalised return value, so returning a model instance fails the task. Return an id or a small dict. ## Checklist for a task signature - Only ids, strings, numbers, booleans, lists and dicts with string keys. - Re-fetch rows inside the task and handle `DoesNotExist`. - Enqueue after commit when the task reads rows created in the same transaction. - Keep arguments stable: a task enqueued before a deploy may run after it.

  • Why does passing a tuple to enqueue() sometimes fail only inside the task?
    The tuple serialises fine but comes back from the JSON round-trip as a list. If the task uses it as a dictionary key or relies on immutability, it raises inside the task, so the result is `FAILED` rather than `enqueue()` raising. Convert explicitly in the task or pass a string key.
  • Everything worked with ImmediateBackend but the task cannot find new rows with a real worker. Why?
    `ImmediateBackend` runs the task in the view's thread, on the view's connection, inside its open transaction, so it sees uncommitted rows. A worker has its own connection and only sees committed data; enqueueing before commit lets it run too early. Enqueue from `transaction.on_commit()`.

saying these in an interview costs you the question

  • Passes model instances to enqueue() and expects Django to pickle them
  • Assumes a tuple argument arrives in the task as a tuple
  • Believes the task sees the object exactly as it was at enqueue time and that this is desirable
  • Enqueues inside an atomic block and assumes the worker sees the uncommitted row
  • Returns a model instance from the task function