A Django admin change list for support tickets runs one extra query per row after adding a customer company column; why, and how do you fix it?
answer
- count the queries per page
- which columns get joined for you
- callables and __ paths are not
- an option naming relations to join
- 6.1 deprecated one value
basics
~20 sBy default the change list joins only ForeignKeys named directly in list_display; a method or customer__company__name path reaches the relation lazily per row. Set list_select_related to the relations used, or join them in get_queryset(), to return to a fixed query count.
solid answer
~40 sWith `list_select_related = False` (the default), the change list calls `select_related()` only for `ForeignKey` fields named **directly** in `list_display`; since Django 6.1 it joins exactly those fields rather than all foreign keys. A method that reads `obj.customer.company.name`, or a `customer__company__name` entry, is not a direct field, so each of the 100 rows on a page (`list_per_page` defaults to 100) lazily fetches its customer and then its company: up to 200 extra queries. The fix is `list_select_related = ["customer__company"]`, or overriding `get_list_select_related()`, or `get_queryset()` for `prefetch_related` and annotations. `list_select_related = True` is deprecated in 6.1; name the relations. Confirm with a query count before and after.
code
python · 15 linesfrom django.contrib import admin
from helpdesk.models import Ticket
@admin.register(Ticket)
class TicketAdmin(admin.ModelAdmin):
list_display = ["reference", "subject", "customer", "company_name", "assignee"]
# "customer" and "assignee" would be joined automatically;
# company_name walks customer -> company and would not be.
list_select_related = ["customer__company", "assignee"]
@admin.display(description="Company", ordering="customer__company__name")
def company_name(self, obj):
return obj.customer.company.namego deeper
Recall that a column reading a related object can cost one query per row, and that list_select_related exists to prevent it.
Explain which list_display entries the change list joins automatically and which it does not, and when to use get_queryset() instead.
Show the diagnosis loop: count queries, map repeats to columns, name relations explicitly, and pin the count in a test; know the 6.1 deprecation of True.
Treat admin pages as production surfaces with query budgets, reviewed like any other endpoint as tables grow.
## The symptom A help-desk change list rendered quickly until someone added a "Company" column. Now each page issues roughly one or two queries per row. Nothing in the code looks like a loop, which is why interviewers use this scenario: the loop is the change list's row rendering, and the query hides behind attribute access on each ticket. ## What the change list joins for you The admin's `ChangeList.apply_select_related()` decides the joins: | `list_select_related` | What the change list does | |---|---| | `False` (default) | `select_related()` on each `ForeignKey` named **directly** in `list_display`, and nothing else | | a list or tuple of paths | `select_related(*paths)` with exactly those paths | | `True` | `select_related()` with no arguments; **deprecated in Django 6.1** | Before 6.1, the default mode called `select_related()` with no arguments as soon as a related field appeared in `list_display`, joining foreign keys nobody displayed; **Django 6.1** narrowed it to only the foreign keys listed. Either way, the automatic join covers a column like `"customer"` (rendered with the customer's `__str__()`), but not: - a `ModelAdmin` method or model property that walks `obj.customer.company`; - a `__` entry such as `"customer__company__name"` (supported in `list_display` since 5.1), because it is not a direct field of the model; - a relation used only inside the related object's `__str__()`, for example a customer whose `__str__` includes the company name. Each of those is a lazy attribute access per row, and on a 100-row page that is 100 or 200 queries. ## Diagnosing it 1. Count queries for one page load: Django Debug Toolbar in development, or `assertNumQueries` around a client request to the change list URL in a test. 2. Look for repeated `SELECT ... WHERE id = %s` against the same table; that is the per-row fetch. 3. Map each repeated query to a column: which method or `__str__` touches that relation? ## Fixing it - **`list_select_related = ["customer__company"]`** joins both hops in the main query. List every relation the columns touch, including those used by related `__str__()` methods. - **`get_list_select_related(request)`** returns the same thing dynamically, for example only when a column is visible to a certain group. - **`get_queryset(request)`** is the place for what `select_related` cannot do: `prefetch_related("tags")` for a many-to-many column, or `annotate(n_replies=Count("replies"))` for a count column. - Replace `True` with explicit paths when upgrading to 6.1; beyond the deprecation, `True` joins relations nobody displays. After the change, a page should run a small fixed number of queries whatever `list_per_page` is: the count, the page itself, and one per prefetch. ## A worked count On a 100-row page, before the fix: 1. one query counts the filtered rows for pagination; 2. one query loads the page of tickets, joined to `customer` automatically; 3. up to 100 queries load each ticket's company through the method. A `__str__` on the customer that also mentions the company would add the same cost again. After `list_select_related = ["customer__company", "assignee"]`, step 3 disappears and the page is back to a handful of queries that does not change when `list_per_page` goes from 100 to 200. ## Related costs on the same page Two other queries often show up in the same investigation, and each has its own owner: - **Filters.** A plain related field in `list_filter` loads every candidate related object into the sidebar; `RelatedOnlyFieldListFilter` restricts it to referenced ones. - **Counts on big tables.** The unfiltered total count and the paginator are separate tuning topics. ## Why it matters in production Staff pages are often the least tested and the first to degrade as tables grow. A change list that issues a few hundred small queries per page is slow for the user, holds a database connection for the whole render, and multiplies with every agent refreshing the queue. The fix is one line, but only if someone counts.
- What changed in Django 6.1 about the admin's list_select_related?With the default `False`, the change list now joins only the foreign keys named in `list_display` instead of every foreign key, which helps models with many foreign keys. Setting `list_select_related = True`, or returning `True` from `get_list_select_related()`, is deprecated (a `RemovedInDjango70Warning`); you are expected to name the relations to fetch.
- The Django admin change list shows a ManyToMany tags column via a method; can list_select_related fix it?No. `select_related` only follows single-valued relations. Override `get_queryset()` to add `prefetch_related("tags")`, so all tags for the page arrive in one extra query, and have the method join the prefetched names without further filtering, which would bypass the prefetch cache.
saying these in an interview costs you the question
- The admin joins every relation any column touches, so per-row queries are impossible
- list_select_related = True is the recommended fix on Django 6.1
- Adding a __ path to list_display makes the change list join that relation
- list_select_related can also batch-load ManyToMany columns
- Lowering list_per_page is the proper fix for per-row queries