Оптимизация админки Django: как устроено внутри
Стандартная админка Django начинает тормозить задолго до того, как тормозит сам проект. Разбираем, почему list_display с ForeignKey порождает сотни запросов, как ModelAdmin строит QuerySet и где именно теряется время на страницах с 50+ объектами.

Почему админка замедляется раньше всего
Django admin — не dashboard для аналитиков и не публичная страница. Это внутренний инструмент, который разработчики запускают на дефолтных настройках и забывают о нём до момента, когда менеджер жалуется: «список заказов грузится десять секунд». На проекте с полумиллионом записей в таблице заказов страница changelist может генерировать от 200 до 2 000 SQL-запросов — при том что отображает всего 100 строк. Проблема не в ORM и не в базе, а в том, как ModelAdmin собирает данные для каждой строки.
Как ModelAdmin строит changelist-запрос
При открытии списка объектов Django вызывает метод get_queryset, затем передаёт результат в класс ChangeList. Тот отвечает за фильтрацию, сортировку и пагинацию. Ключевой момент: get_queryset по умолчанию возвращает Model.objects.all() без select_related и без prefetch_related. Если в list_display указано поле через ForeignKey — скажем, order.customer.name — Django выполнит отдельный SELECT для каждой строки.
Цена N+1 на реальных цифрах
Допустим, модель Order ссылается на Customer и Product. В list_display три поля, два из которых тянут связанные объекты. При list_per_page = 100 (значение по умолчанию) получаем:
- 1 запрос на выборку заказов;
- 100 запросов на Customer;
- 100 запросов на Product;
- ещё 1–3 запроса на подсчёт, пагинацию и фильтры.
Итого — порядка 203 запросов. Добавьте list_filter по связанным моделям, и к ним прибавятся ещё SELECT DISTINCT для выпадающих списков.
select_related и prefetch_related в get_queryset
Самый прямой способ убрать N+1 — переопределить get_queryset:
# Django 5.x, admin.py
from django.contrib import admin
from .models import Order
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ("id", "customer_name", "product_title", "created_at")
list_select_related = ("customer", "product") # встроенный атрибут с Django 1.8+
@admin.display(description="Клиент")
def customer_name(self, obj):
return obj.customer.name
@admin.display(description="Товар")
def product_title(self, obj):
return obj.product.title
Атрибут list_select_related — штатный способ, который появился в Django 1.8. Он подставляет select_related в queryset changelist. Если нужен prefetch_related (для ManyToMany или обратных связей), его в атрибутах нет — переопределяйте get_queryset вручную:
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.prefetch_related("tags") # M2M-поле
После добавления list_select_related количество запросов в примере выше падает с ~203 до 3–5.
show_full_result_count и подсчёт строк
Внизу changelist Django показывает «Всего 523 841 запись». Для этого выполняется SELECT COUNT(*) FROM orders. На таблицах от сотен тысяч строк в PostgreSQL этот запрос может занимать секунды, потому что PostgreSQL выполняет полное сканирование для точного COUNT в MVCC-модели.
Начиная с Django 1.8 доступен атрибут show_full_result_count = False. Он отключает полный COUNT и оставляет только приблизительное «более 100 результатов». На больших таблицах это экономит от 200 мс до нескольких секунд на каждый запрос changelist.
Полный COUNT — главный скрытый тормоз на таблицах с миллионами строк. Его отключение ничего не ломает для пользователя, но убирает самый тяжёлый запрос со страницы.
Что происходит при поиске: search_fields и LIKE
Если search_fields содержит текстовые поля, Django генерирует цепочку ILIKE '%запрос%' (в PostgreSQL) или LIKE '%запрос%' (в MySQL). Оператор с ведущим % не использует B-tree индекс — это полное сканирование.
Три варианта ускорения поиска
| Подход | Что даёт | Чем платите |
|---|---|---|
Префиксный поиск (search_fields = ("^name",)) | Генерирует LIKE 'запрос%', использует B-tree индекс | Ищет только по началу строки |
GIN/GiST-индекс + get_search_results | Полнотекстовый поиск PostgreSQL, работает на миллионах строк | Нужен PostgreSQL, миграция для создания индекса, свой код в get_search_results |
| Поиск по числовому полю | Точное совпадение, индекс по первичному ключу | Не ищет по текстовым полям |
Переключение с search_fields = ("name",) на ("^name",) — изменение в одну строку, которое убирает полное сканирование для большинства кейсов. Если нужен поиск по подстроке в середине, без GIN-индекса или внешнего поискового движка не обойтись.
Инлайны и каскад запросов
Страница редактирования объекта (change_form) с инлайнами — отдельный источник проблем. Каждый TabularInline или StackedInline выполняет свой запрос на выборку связанных объектов. Если инлайн содержит ForeignKey на третью модель — снова N+1.
Решение аналогичное: переопределите get_queryset в самом инлайне:
class OrderItemInline(admin.TabularInline):
model = OrderItem
extra = 0 # не рисуем пустые формы — меньше HTML и JS
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related("product")
Параметр extra = 0 заслуживает внимания: по умолчанию Django рисует три пустые формы для добавления. Каждая форма — HTML-разметка с виджетами, которые браузер парсит и рендерит. На странице с пятью инлайнами это 15 лишних форм, и на слабых клиентах это заметно.
raw_id_fields вместо выпадающего списка
ForeignKey-поле в форме по умолчанию отображается как <select>. Django загружает все объекты связанной модели, чтобы построить список. Если Customer содержит 100 000 записей, Django выполнит SELECT id, __str__ FROM customer, а браузер попытается отрисовать 100 000 <option>.
raw_id_fields = ("customer",) заменяет <select> на текстовое поле с ID и кнопку «лупа». Ни одного лишнего запроса, ни одного лишнего тега. Для Django 5.x также доступен autocomplete_fields, который добавляет автодополнение через Select2 и подгружает данные постранично через AJAX — но требует определения search_fields на связанном ModelAdmin.
* N+1 (проблема N+1 запросов) — ситуация, при которой ORM выполняет 1 запрос на выборку списка и затем N дополнительных запросов для получения связанных данных каждого элемента.
Граничные случаи и где это ломается
- Кастомные методы в list_display. Если метод обращается к связанным объектам через Python-логику, а не через ORM-атрибуты,
list_select_relatedне поможет. Типичный пример — метод, считающийobj.items.count(). Это отдельный COUNT-запрос на каждую строку. Решение — аннотация вget_queryset. - Фильтры по связанным моделям.
list_filter = ("customer__city",)генерируетSELECT DISTINCT city FROM customer. На больших таблицах — секунды. Альтернатива: писать свойSimpleListFilterс ограниченным набором значений. - Аннотации и сортировка. Если добавить аннотированное поле (скажем, сумму позиций заказа) и включить его в
list_displayс возможностью сортировки, PostgreSQL будет выполнять подзапрос или JOIN на каждый changelist-запрос. На таблицах с миллионами строк стоит хранить агрегат в денормализованном поле и обновлять его через сигналы или триггеры. - Django Debug Toolbar. Без него вы не видите проблемы. Toolbar показывает количество запросов, дубликаты и время каждого. Ставьте его в dev-окружение первым делом. Но помните: сам Toolbar добавляет накладные расходы, и на changelist с 500 запросами может выжирать несколько сотен мегабайт памяти браузера для отрисовки панели SQL.
Что делать дальше
После базовой оптимизации changelist и change_form измерьте результат: включите логирование SQL в настройках Django или используйте Django Debug Toolbar, зафиксируйте количество запросов и суммарное время. Отдельная тема — кастомизация админки через переопределение шаблонов и замена её на сторонние решения вроде django-unfold или Grappelli, но это уже вопрос интерфейса, а не производительности запросов. Если changelist всё ещё медленный после устранения N+1 и отключения полного COUNT, следующий шаг — анализ планов запросов через EXPLAIN ANALYZE в PostgreSQL и создание составных индексов под конкретные фильтры и сортировки, которые использует ваш ModelAdmin.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Django documentation — The Django admin site (ModelAdmin options), Django documentation — Database access optimization
Частые вопросы
Сколько запросов к базе — нормально для одной страницы changelist?
list_select_related и переопределение get_queryset — в чём разница?
Поможет ли оптимизация админки, если у меня другая версия Django?
Не сломает ли show_full_result_count = False что-нибудь в админке?
Что если после всех оптимизаций changelist всё ещё тормозит?
Стоит ли заменять стандартную админку на стороннее решение ради производительности?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.