Отчёты и выгрузки в Django: где всё ломается на проде
Экспорт в Excel, который держит весь QuerySet в памяти, и CSV, который блокирует воркер на минуту, — типичные ошибки генерации отчётов в Django. Разбираем, как их избежать.

Отчёт на 200 тысяч строк собирается пять минут, воркер Gunicorn всё это время занят одним запросом, а память процесса вырастает до полутора гигабайт и его убивает OOM-killer. Ровно этот сценарий повторяется в каждом втором проекте, где выгрузку писали «чтобы работало», а объём данных вырос уже потом. Ниже — четыре ошибки, из-за которых генерация отчётов в Django (актуально для 4.2 LTS и 5.x) кладёт прод, и что делать вместо них.
Ошибка 1: материализация всего QuerySet в память
Самый частый паттерн: собрать все объекты в список и пройтись циклом.
# Так делать не надо на больших объёмах
rows = list(Order.objects.all()) # весь QuerySet в память
for order in rows:
writer.writerow([order.id, order.total, order.created_at])
QuerySet ленивый, но list() и любой срез с материализацией тянут все строки разом. На 200 тысячах записей с несколькими полями это сотни мегабайт — и это только Python-объекты, без учёта оверхеда ORM.
Решение — iterator(). Он не кеширует результат и читает данные порциями через серверный курсор, если драйвер это поддерживает.
# psycopg (PostgreSQL) читает батчами по chunk_size
for order in Order.objects.all().iterator(chunk_size=2000):
writer.writerow([order.id, order.total, order.created_at])
Параметр chunk_size задаёт размер батча. Значение по умолчанию — 2000; поднимать выше 5000 обычно смысла нет, память снова начинает расти. Точное поведение iterator() и работу с серверными курсорами смотрите в документации Django по QuerySet API — оно зависит от версии и от бэкенда БД.
Важная оговорка про prefetch_related
До Django 4.1 iterator() молча игнорировал prefetch_related. Начиная с 4.1 его можно комбинировать, но только если явно передать chunk_size. Если у вас связанные объекты и старая версия — либо обновляйтесь, либо разворачивайте связи через select_related и values().
Ошибка 2: воркер занят на всё время генерации
Даже с iterator() синхронный view держит воркер занятым, пока файл не собран. При типичной конфигурации Gunicorn на 4 воркера три параллельные выгрузки по минуте каждая — и на четвёртой запрос обычного пользователя встаёт в очередь.
Тяжёлый отчёт — это не HTTP-запрос, это фоновая задача, притворяющаяся HTTP-запросом. Пока вы держите её в воркере, вы платите за это доступностью всего сайта.
Два рабочих подхода:
- StreamingHttpResponse — отдавать файл потоком по мере генерации. Воркер всё ещё занят, но память не растёт, и таймаут на стороне прокси не срабатывает, потому что байты идут постоянно.
- Фоновая задача через Celery — генерировать файл в отдельном процессе, класть в хранилище, а пользователю показывать ссылку, когда готово. Единственный вариант, который снимает нагрузку с веб-воркеров полностью.
Потоковая отдача CSV выглядит так:
import csv
from django.http import StreamingHttpResponse
class Echo:
# writerow пишет в этот объект, а он просто возвращает строку
def write(self, value):
return value
def export_orders(request):
writer = csv.writer(Echo())
queryset = Order.objects.all().iterator(chunk_size=2000)
def rows():
yield writer.writerow(['id', 'total', 'created_at'])
for order in queryset:
yield writer.writerow([order.id, order.total, order.created_at])
response = StreamingHttpResponse(rows(), content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename="orders.csv"'
return response
Трюк с классом Echo — стандартный приём из документации Django: csv.writer ожидает объект с методом write, а мы возвращаем ему строку, чтобы отдать генератору наружу.
Когда что выбирать
| Критерий | StreamingHttpResponse | Celery + ссылка |
|---|---|---|
| Время генерации | до ~30 секунд | от 30 секунд и дольше |
| Нагрузка на веб-воркеры | воркер занят | воркеры свободны |
| Формат | CSV, потоковый вывод | любой, включая Excel |
| Сложность | низкая | нужен брокер и хранилище |
| UX | браузер качает сразу | «файл готовится, придёт ссылка» |
Порог в 30 секунд — не догма, а типичный таймаут Nginx и Gunicorn по умолчанию. Если генерация к нему подбирается, пора уходить в Celery.
Ошибка 3: Excel через openpyxl без write_only
Excel-выгрузки любят делать через openpyxl, и по умолчанию библиотека держит в памяти всю книгу целиком — каждую ячейку как объект. На десятках тысяч строк это гигабайты.
У openpyxl есть режим write_only=True: ячейки сбрасываются на диск по мере добавления строк, в памяти остаётся только текущий фрагмент.
from openpyxl import Workbook
# write_only резко снижает потребление памяти
workbook = Workbook(write_only=True)
sheet = workbook.create_sheet()
sheet.append(['id', 'total', 'created_at'])
for order in Order.objects.values_list(
'id', 'total', 'created_at'
).iterator(chunk_size=2000):
sheet.append(list(order))
workbook.save('/tmp/orders.xlsx')
Обратите внимание на values_list вместо полноценных объектов: если вам нужны только значения для ячеек, не поднимайте модели целиком. Это экономит и память, и время на инстанцирование ORM-объектов.
Точный набор возможностей write_only (например, ограничения на стили ячеек в этом режиме) сверяйте с документацией openpyxl вашей версии — там есть нюансы с форматированием.
Ошибка 4: N+1 запрос внутри цикла выгрузки
Отчёт по заказам, где для каждой строки дёргается имя клиента:
# На каждой итерации — отдельный SQL-запрос к User
for order in Order.objects.all().iterator(chunk_size=2000):
writer.writerow([order.id, order.customer.name, order.total])
На 100 тысячах заказов это 100 тысяч дополнительных запросов к базе. Даже быстрая БД захлебнётся на сетевых round-trip. Лечится select_related для ForeignKey и prefetch_related для обратных связей и ManyToMany.
# Один JOIN вместо тысяч запросов
queryset = (
Order.objects
.select_related('customer')
.iterator(chunk_size=2000)
)
Проверить, что N+1 исчез, проще всего через django-debug-toolbar на дев-окружении или логированием SQL: если число запросов не зависит от числа строк — вы всё сделали правильно.
Если не получилось: типичные симптомы
- Процесс убивает OOM-killer, в логах
MemoryErrorили сигнал 9 — всё ещё где-то материализуется весь QuerySet. Ищитеlist(), срезы иlen()по QuerySet. - Nginx отдаёт 504 Gateway Timeout — генерация дольше таймаута прокси. Уходите в StreamingHttpResponse или Celery, не поднимайте таймаут до бесконечности.
- Файл скачивается пустым или обрезанным — при потоковой отдаче исключение внутри генератора приходит уже после отправки заголовков. Ловите ошибки внутри
rows()и логируйте. - Excel открывается с ошибкой «файл повреждён» — в
write_onlyкнигу нужно сохранить и закрыть; не отдавайте файл доsave().
Что делать дальше
Первым делом замерьте текущий отчёт: сколько занимает памяти процесс во время генерации (ps или мониторинг) и сколько SQL-запросов улетает в базу. Эти две цифры покажут, какая из четырёх ошибок у вас главная. Дальше по нарастающей: перевести цикл на iterator(), убрать N+1 через select_related, а когда генерация стабильно переваливает за десятки секунд — выносить в Celery и отдавать пользователю ссылку на готовый файл в хранилище. Инвалидация и хранение готовых выгрузок — отдельная тема, здесь мы разобрали только саму генерацию.
Карточки для маркетплейсов: Таблица товаров и папка фотографий на входе — комплект слайдов и описаний на выходе, по требованиям Wildberries и Ozon.
Спросить за 5 ₽Источники: Django QuerySet API reference — iterator(), openpyxl — Optimised modes (write-only)
Частые вопросы
У меня MySQL, а не PostgreSQL — iterator() поможет с памятью?
А если у меня старая версия Django, скажем 3.2?
Перевёл на iterator(), а память всё равно растёт — почему?
Celery — это обязательно для выгрузок?
Как отдать пользователю большой файл, не держа его в памяти сервера?
Стоит ли кешировать готовые отчёты?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.