How can Django 6.1's FETCH_RAISE fetch mode guard a performance-critical view against accidental lazy loads, and what will it not catch?
answer
- exception instead of a query
- FieldFetchBlocked is a FieldError
- mode travels to related objects
- managers and explicit queries slip through
basics
~10 sBuild the view's QuerySet with fetch_mode(models.FETCH_RAISE) plus the select_related/only plan it needs; any unplanned foreign key or deferred-field access raises FieldFetchBlocked. It does not catch related-manager queries or queries the code writes explicitly.
solid answer
~40 s`FETCH_RAISE` makes an unloaded-field access raise `django.core.exceptions.FieldFetchBlocked` (a `FieldError` subclass) with a message like `Fetching of Ticket.assignee blocked.` instead of querying. On a hot endpoint you write the loading plan explicitly — `select_related("assignee")`, `only(...)` — and add `.fetch_mode(models.FETCH_RAISE)`, so a template or serializer change that touches something new fails in tests rather than adding queries in production. Because the mode is copied to fetched related objects, `ticket.assignee.team` is guarded too. What it does **not** catch: reverse `ForeignKey` and many-to-many manager queries (iterating `ticket.comments.all()`), `.count()` or any query the code issues explicitly, and work done by QuerySets built elsewhere without the mode. A forward key whose value is `NULL` also never raises, since nothing is fetched. Pair it with query-count assertions for full coverage.
code
python · 14 linesfrom django.core.exceptions import FieldFetchBlocked
from django.db import models
from helpdesk.models import Ticket
tickets = Ticket.objects.select_related("assignee").fetch_mode(models.FETCH_RAISE)
for t in tickets:
t.assignee # joined: no query, no error
t.comments.count() # related manager: runs a query, not blocked
try:
t.project # not joined: blocked
except FieldFetchBlocked as exc:
print(exc) # Fetching of Ticket.project blocked.go deeper
Recall that FETCH_RAISE turns an unplanned lazy load into a FieldFetchBlocked exception.
Explain which accesses raise, that the mode reaches related objects, and that a NULL foreign key simply returns None.
Use FETCH_RAISE with an explicit select_related/only plan on hot paths, and cover its blind spots — related managers, explicit queries — with query-count tests.
Decide where strict loading pays for its brittleness, and how the team combines it with query budgets in CI.
## What `FETCH_RAISE` does Django 6.1 lets a QuerySet choose what happens when code reads a field that was **not loaded**. Under `models.FETCH_RAISE`, Django raises **`FieldFetchBlocked`** rather than running a query. The exception lives in `django.core.exceptions`, subclasses `FieldError`, and names the model and field: `Fetching of Ticket.assignee blocked.` The covered accesses are the same as for every fetch mode: - forward `ForeignKey` and `OneToOneField` access (`ticket.assignee`); - reverse one-to-one access (`user.profile`); - fields deferred with `defer()` or `only()`; - generic foreign keys. ## Using it as a guard The idea is to **declare the loading plan** and make every deviation loud: ```python from django.db import models def ticket_board(request): tickets = ( Ticket.objects.filter(status="open") .select_related("assignee", "project") .only("id", "title", "status", "assignee__username", "project__name") .fetch_mode(models.FETCH_RAISE) ) ... ``` 1. The plan is explicit: two joins, five columns. 2. If a template later adds `{{ ticket.assignee.email }}` — a column excluded by `only()` — rendering raises `FieldFetchBlocked` in tests. 3. If someone adds `ticket.reporter.username` without joining `reporter`, the same happens. 4. Because Django **copies the fetch mode onto related objects** it builds, `ticket.assignee.team` is guarded as well, not just the top-level tickets. Where to apply it: - **Hot list endpoints** whose query budget is fixed and measured. - **Background jobs** that process large batches, where one lazy load per row is expensive. - **Test suites**, by switching the mode through a custom manager or a queryset method in test settings, to surface lazy loads during CI. ## What it will not catch | Access | Blocked by `FETCH_RAISE`? | |---|---| | `ticket.assignee` not joined | yes | | `ticket.description` excluded by `only()` | yes | | iterating `ticket.comments.all()` (reverse `ForeignKey` manager) | **no** — a new QuerySet that queries when evaluated | | `ticket.watchers.count()` (many-to-many manager) | **no** | | `Ticket.objects.filter(...)` written inside the loop | **no** — an explicit query | | `ticket.assignee` when `assignee_id` is `NULL` | no exception — nothing to fetch, returns `None` | So `FETCH_RAISE` is a guard against **lazy attribute loads**, not a general query budget. Related managers need `prefetch_related()` (whose cached results are then read without a query) or annotations, and a total budget needs a query-count assertion in tests. ## Rolling it out on an existing endpoint 1. Write down the fields the response actually needs and build the QuerySet with `select_related()`, `prefetch_related()` and `only()` to match. 2. Add `.fetch_mode(models.FETCH_RAISE)` and run the endpoint's tests. Every `FieldFetchBlocked` names a relation or column the plan missed; add it or change the consumer. 3. Replace per-row manager calls, such as a comment count, with annotations or prefetches, since `FETCH_RAISE` cannot see them. 4. Add a test that asserts the endpoint's total query count, covering what the mode lets through. 5. Keep the mode in place so the next change to the template or serializer fails the build instead of shipping extra queries. ## Trade-offs - **Brittleness is the point, but it has a cost.** A harmless display change can break a page. Keep `FETCH_RAISE` to endpoints where the query plan is owned and reviewed, not as a project-wide default. - **Errors surface at access time**, often inside a template or serializer, far from the view that built the QuerySet. The message names the model and field, which usually points straight at the missing `select_related()` or `only()` entry. - **Instances made elsewhere keep their own mode.** Objects from a different QuerySet, or created with the constructor (default `FETCH_ONE`), are not guarded. - **Not available before 6.1.** On the 5.2 LTS the nearest substitute is asserting query counts in tests.
- Does FETCH_RAISE protect objects reached through a joined relation?Yes. Django copies an instance's fetch mode onto related objects it builds, so an assignee loaded by `select_related()` on a `FETCH_RAISE` QuerySet also raises if code then reads `assignee.team` without that relation being loaded.
- How do you catch the queries FETCH_RAISE lets through?Related-manager calls and explicit queries are ordinary QuerySets, so they need a different guard: assert the number of queries the endpoint runs in a test, and use `prefetch_related()` or annotations so the code reads cached data instead of calling managers per row.
saying these in an interview costs you the question
- Believes FETCH_RAISE blocks every database query in the block
- Thinks FETCH_RAISE returns None for unloaded fields
- Expects ticket.comments.all() to raise under FETCH_RAISE
- Enables FETCH_RAISE on every model by default in production
- Says FieldFetchBlocked is raised when the QuerySet is evaluated