Raw SQL в Django: когда ORM мешает делу
Аналитический запрос на ORM разросся до пяти подзапросов и стал нечитаемым. Разбираем, когда стоит перейти на сырой SQL в Django 5.x, какие есть три способа и чем каждый платит.

Вы пишете отчёт: выручка по регионам с оконной функцией ROW_NUMBER(), ранжирование внутри группы, пара CTE. На ORM это превращается в цепочку annotate, Subquery, OuterRef и Window, которую через месяц не прочитает даже автор. Django генерирует запрос, но вы уже не понимаете, какой именно, и не можете ручаться, что он эффективен. Это тот момент, когда сырой SQL перестаёт быть костылём и становится правильным инструментом.
Статья про Django 5.x и о том, где ORM мешает, а не помогает: три способа выполнить SQL напрямую, цена каждого и правило выбора. Оптимизацию самих запросов и индексы не трогаем — только интерфейс между Python-кодом и базой.
Где ORM реально мешает
ORM хорош для CRUD и типовых выборок. Он ломается на трёх классах задач:
- Аналитика с оконными функциями и CTE.
WindowиSubqueryв Django есть, но сложный отчёт на них читается хуже, чем эквивалентный SQL, и его тяжело ревьюить. - Массовые операции.
UPDATE ... FROM,INSERT ... ON CONFLICTс хитрой логикой, пакетное обновление по джойну — ORM либо не выражает это напрямую, либо делает построчно. - Функции конкретной СУБД. Полнотекстовый поиск PostgreSQL за пределами
SearchVector,LATERAL-джойны,jsonb-операторы, специфичные для версии.
Во всех остальных случаях сырой SQL — это шаг назад: вы теряете переносимость, защиту от инъекций по умолчанию и связь с моделями. Поэтому вопрос не «умею ли я писать SQL», а «стоит ли платить за отказ от ORM здесь».
Три способа выполнить SQL напрямую
1. Manager.raw() — сырой запрос с маппингом в модель
Метод raw() есть у менеджера каждой модели. Он выполняет SQL и возвращает RawQuerySet — объекты модели, а не кортежи. Подходит, когда результат по структуре близок к таблице модели.
from myapp.models import Order
# Django 5.x, PostgreSQL 16
# Параметры передаём списком — не форматируйте строку сами
sql = """
SELECT id, customer_id, total, created_at
FROM myapp_order
WHERE total > %s AND status = %s
ORDER BY created_at DESC
"""
orders = Order.objects.raw(sql, [1000, "paid"])
for order in orders:
# это полноценные экземпляры Order
print(order.id, order.total)Что важно: в SELECT обязательно должен быть первичный ключ модели, иначе Django не соберёт объект. Поля, которых нет в запросе, подгружаются лениво отдельными запросами при обращении — это скрытый источник N+1.
2. connection.cursor() — полный контроль, никаких моделей
Когда результат не ложится на модель (агрегаты, сводные строки, произвольные колонки), берите курсор напрямую. Возвращаются кортежи, маппинг делаете сами.
from django.db import connection
def revenue_by_region(min_total):
sql = """
SELECT region, SUM(total) AS revenue,
ROW_NUMBER() OVER (ORDER BY SUM(total) DESC) AS rank
FROM myapp_order o
JOIN myapp_customer c ON c.id = o.customer_id
WHERE o.total > %s
GROUP BY region
"""
with connection.cursor() as cursor:
cursor.execute(sql, [min_total])
columns = [col[0] for col in cursor.description]
return [dict(zip(columns, row)) for row in cursor.fetchall()]Приём с cursor.description превращает кортежи в словари по именам колонок — читается лучше, чем обращение по индексу row[0]. Для массовых вставок используйте cursor.executemany(sql, seq_of_params).
3. QuerySet.extra() — устаревший, не используйте
В коде проектов вы встретите extra(). Django официально считает его устаревшим и не рекомендует к новому применению: интерфейс хрупкий, его сложно сделать безопасным. Если пишете новое — выбирайте между raw() и курсором.
Правило простое: результат похож на строки модели —raw(). Результат не про модель или это неSELECT— курсор. Гибрид ORM и SQL внутри одного запроса — сначала попробуйтеFuncиRawSQL-выражения, а сырой запрос целиком оставьте на крайний случай.
Сравнение подходов
| Критерий | raw() | cursor() |
|---|---|---|
| Что возвращает | экземпляры модели | кортежи (или dict вручную) |
| Годится для не-SELECT | нет | да (INSERT/UPDATE/DELETE) |
| Ленивая подгрузка полей | есть, риск N+1 | нет |
| Произвольные колонки | через getattr, неудобно | естественно |
| Связь с моделью | полная | нет |
Защита от инъекций — единственное неотступное правило
Уходя от ORM, вы теряете автоматическое экранирование. Django параметризует запрос сам, если передать значения вторым аргументом. Единственный способ получить SQL-инъекцию — собрать запрос строкой.
- Правильно:
cursor.execute("... WHERE id = %s", [user_id]) - Дыра в безопасности:
cursor.execute(f"... WHERE id = {user_id}")
Отдельная ловушка: имена таблиц и колонок через %s подставить нельзя — плейсхолдер работает только для значений. Если имя колонки приходит извне (сортировка по параметру запроса), проверяйте его по белому списку допустимых значений, а не подставляйте напрямую.
Плейсхолдер %s
Это позиционный параметр драйвера базы данных, а не форматирование строки Python. Драйвер (например, psycopg для PostgreSQL) сам подставит и экранирует значение. Использовать нужно %s для всех бэкендов Django независимо от типа данных — %d писать не нужно.
Когда что выбирать
- Отчёт или дашборд с агрегатами и окнами — курсор, результат в словари. Модели тут только мешают.
- Выборка с хитрым
WHERE, но по одной таблице модели —raw(), получите готовые объекты и работающие методы модели. - Массовое обновление по условию с джойном — курсор с
UPDATE ... FROM. ORM это построчно не потянет эффективно. - Один нестандартный кусок в основном ORM-запросе — не переписывайте весь запрос: посмотрите на
RawSQL-выражение внутриannotate, оно сохранит остальную цепочку.
Что ломается на практике
- N+1 из raw(). Если в
SELECTнет колонки, а вы обращаетесь к атрибуту, Django делает отдельный запрос на каждый объект. Выбирайте все нужные поля явно. - Транзакции. Сырой запрос выполняется в том же соединении и той же транзакции, что и ORM, но
connection.cursor()не открывает транзакцию сам. Оборачивайте изменяющие операции вtransaction.atomic(). - Разные базы в проекте. Если настроено несколько соединений, курсор берётся из конкретного:
connections['analytics'].cursor(). Обычныйconnection— этоdefault. - Тесты. Сырой SQL не проверяется мигратором на соответствие схеме. Переименовали таблицу в модели — запрос со старым именем упадёт только в рантайме. Покрывайте такие места тестами.
Что делать дальше
Перед тем как выносить запрос в сырой SQL, включите логирование и посмотрите, что генерирует ORM: print(queryset.query) или логгер django.db.backends в settings. Часто оказывается, что читаемость страдает из-за неудачной сборки запроса, а не из-за принципиального ограничения ORM. Если решили перейти на SQL — изолируйте его в отдельном методе менеджера или в функции слоя данных, а не размазывайте cursor.execute по вьюхам. Тогда при смене СУБД или рефакторинге вы будете точно знать, где именно завязались на конкретную базу.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Django docs — Performing raw SQL queries, Django docs — Running custom SQL directly
Частые вопросы
А если у меня Django 4.2, эти способы работают?
Запрос через cursor() не видит данные, которые я только что создал в тесте — почему?
Можно ли подставить имя таблицы или колонки через %s?
raw() делает лишние запросы к базе — как поймать?
Стоит ли вообще уходить от ORM ради производительности?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.