Привет, Хабр!

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 на стероидах: большие данные, высокие нагрузки и масштабирование без боли». Записаться

Больше бесплатных уроков и других полезных подборок смотрите в дайджесте.

Комментарии (2)


  1. Jack444
    27.07.2026 17:00

    Для меня джанго умер.
    Столько лет asyncio внедрял и если не ошибаюсь до сих пор асинхронных транзакций нету.
    Сейчас на питоне со свободными потоками можно делать полноценный монолит с общей памятью и локальными очередями, производительность выростает кратно в сравнении использования кролика и редиски для общения воркеров.


  1. abbasov-alexander
    27.07.2026 17:00

    Поспорю, что Django умер. Просто это решение не под высокие нагрузки. К примеру, для B2B проектов, при правильном подходе, размере БД меньше 10Гб, то он будет очень даже сносно работать. Для соцсети да, не подойдет) Но для проектов, которым нужны из коробки система прав, система шаблонов, MVC архитектура и куча батареек в комплекте — самое то. А если правда нужно решение под высокие нагрузки, так для этого есть Go и микросервисы. А все эти async в Python от лукавого, имхо.