Привет, Хабр!
ORM в Django устроена так, что писать неоптимальный код в ней приятно и незаметно. Вы пишете обычный питон, он превращается в SQL где‑то далеко от вас, и пока в базе тысяча строк, всё летает. Проблемы начинаются, когда строк становится миллион, а пользователей — сотня одновременно.
Причём падает обычно не то, на что вы смотрели. N+1 запросы все давно знают и ловят, а вот подсчёты, которые незаметно выкачивают таблицу в память, транзакции, которые держат блокировку дольше нужного, и гонки в самых обычных save() — это уже интереснее.
Разберём шесть таких мест.
Ошибка первая: len() вместо count() и наоборот
Начнём с малого, но очень частого.
users = User.objects.filter(is_active=True) print(f"Активных пользователей: {len(users)}")
Выглядит безобидно. На деле len() заставляет queryset выполниться целиком: Django выкачивает все строки, собирает из них питоновские объекты и только потом считает их длину. Миллион пользователей — миллион объектов в памяти ради одного числа.
count = User.objects.filter(is_active=True).count() # SELECT COUNT(*), одно число
Но и обратная ошибка встречается не реже:
users = User.objects.filter(is_active=True) print(f"Всего: {users.count()}") # первый запрос for user in users: # второй запрос, теперь уже за данными send_email(user)
Здесь count() наоборот лишний: вы всё равно собираетесь пройтись по всем объектам, значит, выборка уже будет в памяти, и длину можно взять бесплатно.
Если объекты дальше нужны — берите
len()или сначала материализуйте queryset в список.Если нужно только число —
count().
Отдельно про проверку на пустоту. Вот так делать не надо:
if User.objects.filter(email=email).count() > 0: # считает все совпадения if User.objects.filter(email=email): # выкачивает все объекты
Есть специальный метод, который на уровне SQL превращается в SELECT ... LIMIT 1:
if User.objects.filter(email=email).exists():
База останавливается на первой найденной строке и не считает остальные. На таблице с миллионом записей разница между exists() и count() — это разница между миллисекундой и секундами.
Ошибка вторая: ленивость, о которой забыли
Queryset ленивый — он не ходит в базу, пока его не попросят. Это удобно, пока вы не забываете, что каждое «попросят» — это отдельный поход.
orders = Order.objects.filter(status='paid') total = sum(o.amount for o in orders) # запрос №1 count = len(orders) # кеш, повезло recent = orders[:10] # запрос №2 — новый queryset! first = orders.first() # запрос №3
Первое обращение выполняет queryset и кеширует результат внутри объекта. Но срез orders[:10] создаёт новый queryset со своим LIMIT, и он идёт в базу заново. То же с .first(), .filter(), .exclude() — любой метод, возвращающий queryset, начинает историю с нуля.
Ещё веселее это выглядит в шаблонах:
{% if orders %} Всего заказов: {{ orders|length }} {% for order in orders %}...{% endfor %} {% endif %}
Первое {% if %} выполнит queryset и закеширует, остальные возьмут из кеша. А вот если между ними затесался {{ orders.count }} — это уже отдельный запрос в базу.
Когда точно знаете, что будете обращаться несколько раз, материализуйте явно:
orders = list(Order.objects.filter(status='paid'))
Дальше это обычный список, никаких сюрпризов.
Обратная сторона — когда материализовать нельзя, потому что данных слишком много:
for order in Order.objects.all(): # весь миллион в память process(order) for order in Order.objects.all().iterator(chunk_size=2000): # порциями process(order)
iterator() не кеширует результат и тянет строки пачками.
Ошибка третья: select_related и prefetch_related выбраны наугад
Про N+1 знают все, но выбирают инструмент часто по принципу «какой вспомнил».
for order in Order.objects.all(): print(order.customer.name) # +1 запрос на каждый заказ
Тысяча заказов — тысяча один запрос. Фиксится в зависимости от типа связи.
select_related работает для «многие к одному» и «один к одному», то есть когда связь идёт через внешний ключ на вашей модели. Django добавляет JOIN и достаёт всё одним запросом.
Order.objects.select_related('customer', 'customer__city')
prefetch_related работает для «многие ко многим» и обратных связей. JOIN тут не годится — он бы размножил строки, поэтому Django делает второй запрос и склеивает объекты в питоне.
Order.objects.prefetch_related('items')
Путаница обходится дорого в обе стороны. Возьмёте select_related там, где нужен prefetch_related, получите ошибку. Возьмёте prefetch_related для простого внешнего ключа — получите лишний запрос там, где хватило бы JOIN.
И третья ловушка: prefetch_related с дополнительной фильтрацией внутри цикла всё ломает.
orders = Order.objects.prefetch_related('items') for order in orders: active = order.items.filter(is_active=True) # предзагрузка не используется!
Как только вы применяете .filter() к предзагруженной связи, Django идёт в базу заново — потому что в кеше лежит другой набор. Фильтровать надо сразу:
from django.db.models import Prefetch orders = Order.objects.prefetch_related( Prefetch('items', queryset=OrderItem.objects.filter(is_active=True)) )
Теперь в кеше лежит именно то, что нужно, и цикл обходится без запросов.
Индексы, которых нет
Раз уж речь о связях, стоит сказать про соседнюю проблему. Django автоматически создаёт индекс на внешние ключи, а вот на обычные поля — нет. Поэтому фильтр вида.
Order.objects.filter(status='paid', created_at__gte=week_ago)
на большой таблице будет сканировать её целиком, пока вы не объявите индекс явно:
class Order(models.Model): class Meta: indexes = [ models.Index(fields=['status', 'created_at']), ]
Порядок полей в составном индексе важен: база сможет использовать его для фильтра по status отдельно, но не для фильтра по одному created_at. Правило такое — сначала поля, по которым идёт точное сравнение, потом те, по которым диапазон.
Ошибка четвёртая: гонка в обычном save()
def add_view(product_id): product = Product.objects.get(id=product_id) product.views_count += 1 product.save()
Между чтением и записью проходит время. Два запроса читают одно и то же значение, каждый прибавляет свою единицу, второй перезаписывает первого. Счётчик растёт медленнее реальности, и понять это по логам невозможно.
Хуже того, save() по умолчанию пишет все поля модели. Значит, вы затрёте и те, которые кто‑то поменял параллельно, не только счётчик.
Фиксится выражением F, которое переносит вычисление в базу:
Product.objects.filter(id=product_id).update(views_count=F('views_count') + 1)
Получается один атомарный UPDATE products SET views_count = views_count + 1, никаких гонок. Причём здесь же решается и вторая проблема: update() трогает только указанные колонки.
Если всё‑таки нужен именно save(), ограничивайте набор полей:
product.name = new_name product.save(update_fields=['name'])
Заодно это заметно быстрее на широких таблицах.
А когда логика сложнее одного инкремента и F не спасает, берите блокировку строки:
with transaction.atomic(): product = Product.objects.select_for_update().get(id=product_id) if product.stock >= quantity: product.stock -= quantity product.save(update_fields=['stock'])
select_for_update() ставит блокировку до конца транзакции, и второй запрос будет ждать. Два условия: работать только внутри transaction.atomic() (иначе Django бросит ошибку) и держать блок как можно короче.
Ошибка пятая: транзакция, в которой живёт лишнее
Транзакции легко расползаются.
@transaction.atomic def create_order(request): order = Order.objects.create(...) send_confirmation_email(order) # сеть, секунды charge_payment_gateway(order) # сеть, ещё секунды generate_pdf_invoice(order) # процессор, ещё секунды return order
Пока выполняется всё это, транзакция открыта, соединение с базой занято, а блокировки удерживаются.
При десятке одновременных запросов пул соединений заканчивается, и приложение встаёт — при том что база практически ничем не занята.
Плюс здесь есть логическая ошибка: письмо уйдёт даже если транзакция потом откатится. Пользователь получит подтверждение заказа, которого не существует.
Правильный ход — держать в транзакции только работу с базой, а побочные эффекты вешать на успешный коммит:
def create_order(request): with transaction.atomic(): order = Order.objects.create(...) OrderItem.objects.bulk_create(items) transaction.on_commit(lambda: send_confirmation_email.delay(order.id)) return order
on_commit запускает колбэк только после того, как транзакция реально зафиксирована. Откатилась — колбэк не выполнится вообще.
Тот же приём обязателен при постановке задач в очередь. Без него получается классическая гонка: воркер Celery подхватывает задачу быстрее, чем коммитится транзакция, идёт в базу за объектом и не находит его.
# так задача может стартовать раньше коммита process_order.delay(order.id) # так — только после transaction.on_commit(lambda: process_order.delay(order.id))
Ещё одна деталь про транзакции: ATOMIC_REQUESTS = True в настройках оборачивает каждый запрос целиком. Звучит удобно, а на практике означает открытую транзакцию на всё время обработки запроса, включая рендеринг шаблонов и походы во внешние сервисы. Для нагруженного проекта это плохая идея — лучше расставлять atomic точечно.
get_or_create и то, чем он кажется
Раз уж речь о гонках, стоит упомянуть метод, который выглядит атомарным, но таковым не является:
user, created = User.objects.get_or_create(email=email, defaults={'name': name})
Внутри это SELECT, а если не нашли — INSERT. Между ними успевает вклиниться другой запрос, и вы получите IntegrityError вместо ожидаемого объекта. Django ловит эту ситуацию и делает повторный SELECT, но только при одном условии: на поле должно стоять ограничение уникальности в базе.
Без unique=True метод молча создаст дубликаты, и вы об этом узнаете сильно позже — когда в таблице обнаружатся два пользователя с одной почтой.
Так что правило простое: get_or_create без уникального индекса на полях поиска — это не защита от дублей, а её имитация.
Ошибка шестая: массовые операции в цикле
Последнее, что убивает производительность на ровном месте.
for row in csv_rows: Product.objects.create(name=row['name'], price=row['price'])
Десять тысяч строк — десять тысяч отдельных INSERT, каждый со своим круговым походом до базы. Даже на локальной машине это минуты.
Product.objects.bulk_create( [Product(name=r['name'], price=r['price']) for r in csv_rows], batch_size=1000, )
Один запрос на тысячу объектов. Разница обычно в десятки раз.
То же с обновлением:
for product in products: product.price *= 1.1 Product.objects.bulk_update(products, ['price'], batch_size=1000)
Только имейте в виду цену этой скорости. bulk_create и bulk_update не вызывают save() у моделей и не шлют сигналы pre_save и post_save. Если у вас на сигналах висит обновление поискового индекса, инвалидация кеша или запись в аудит‑лог, при массовой вставке всё это молча не сработает.
Это, кстати, хороший аргумент против того, чтобы вешать критичную логику на сигналы. Явный вызов функции виден в коде и не теряется при переходе на массовые операции; сигнал — не виден и теряется.
Если нужно и быстро, и с побочными эффектами, делайте их явно после вставки:
created = Product.objects.bulk_create(objs, batch_size=1000) reindex_products.delay([p.id for p in created])
С Django 4.1 bulk_create возвращает объекты с проставленными id (на PostgreSQL это работало и раньше), так что собрать список для последующей обработки можно сразу.
Как всё это увидеть до прода
Хорошая новость: почти всё перечисленное ловится инструментами.
Django Debug Toolbar показывает количество запросов на странице и дублирующиеся среди них. Если на списке из двадцати элементов видно двадцать одинаковых запросов — вот вам N+1 без всякого расследования.
Тест на количество запросов. Самая недооценённая вещь: зафиксировать в тесте, сколько запросов должна делать вьюха.
def test_order_list_queries(client, django_assert_num_queries): with django_assert_num_queries(3): client.get('/orders/')
Кто‑нибудь добавит обращение к связанной модели в шаблон — тест покраснеет сразу, а не через полгода на проде. Это буквально одна строка защиты от самой частой проблемы в джанго.
Логирование медленных запросов. В настройках включается вывод SQL, а на стороне PostgreSQL —
log_min_duration_statement. Смотреть на реальные запросы под реальной нагрузкой полезнее любых предположений.
.explain() прямо на queryset:
print(Order.objects.filter(status='paid').explain(analyze=True))
Показывает план выполнения. Если видите последовательное сканирование по большой таблице — скорее всего, не хватает индекса.
Посмотреть сгенерированный SQL можно вообще без всяких инструментов:
print(Order.objects.filter(status='paid').select_related('customer').query)
Полминуты работы, а показывает ровно то, что уедет в базу. Привычка заглядывать туда при написании сложного queryset экономит потом часы.
assertNumQueries в связке с фикстурами реального объёма. Тест на количество запросов бесполезен, если в тестовой базе три записи: N+1 там даст четыре запроса вместо трёх, и никто не заметит. Заводите фикстуру хотя бы на пару десятков связанных объектов — тогда разница станет очевидной.
Про asyn
В шестой версии асинхронная часть ORM подросла: асинхронных методов стало больше, появился AsyncPaginator, писать sync_to_async вокруг каждого обращения к базе больше не нужно.
Но одну вещь стоит понимать правильно, потому что вокруг неё много восторженных текстов. Асинхронные методы вроде aget, acreate, acount — это удобный интерфейс, а не что‑то невероятное: под капотом запрос по‑прежнему выполняется синхронным драйвером в пуле потоков. Настоящего асинхронного ввода‑вывода до базы в Django пока нет.
await перед запросом не делает его дешевле и не увеличивает пропускную способность базы. Он избавляет вас от блокировки цикла событий и от ручных обёрток. А вот количество одновременных подключений и время выполнения запроса остаются теми же, и все ошибки выше в асинхронном коде работают точно так же.
async def order_list(request): orders = [o async for o in Order.objects.select_related('customer')[:20]] total = await Order.objects.acount() return JsonResponse({...})
Обратите внимание на select_related — он тут так же обязателен, как в синхронном коде. Асинхронность не спасает от N+1, она просто делает его асинхронным.
Если посмотреть на шесть историй разом, у них одна природа: ORM позволяет не думать о базе, и большую часть времени это правда работает. Проблема в том, что расстояние между вашим питоном и настоящим SQL достаточно большое, чтобы туда помещался лишний запрос, лишняя транзакция или лишний миллион объектов в памяти.
Поэтому единственная привычка, которая реально помогает, — время от времени смотреть на то, во что превращается ваш код.

Проблемы ORM проще разбирать, когда видно, что происходит на уровне запросов, базы и нагрузки. На бесплатных занятиях можно закрыть отдельные пробелы, задать вопросы преподавателям‑практикам и заодно посмотреть, как устроено обучение в OTUS.
11 августа, 20:00. «Работа с SQLAlchemy и Alembic в FastAPI». Записаться
13 августа, 20:00. «Минимум для старта: как провести свое первое нагрузочное тестирование». Записаться
19 августа, 20:00. «PostgreSQL на стероидах: большие данные, высокие нагрузки и масштабирование без боли». Записаться
Больше бесплатных уроков и других полезных подборок смотрите в дайджесте.
