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

Экспорт «Скачать отчёт» — классика, которая отлично живёт на демо и разваливается в проде. На локальной базе с двумя тысячами заказов кнопка отдаёт CSV за долю секунды. На боевой базе с полумиллионом строк тот же view держит воркер Gunicorn 40 секунд, упирается в таймаут Nginx и отдаёт 504, а параллельно пользователь жмёт кнопку ещё три раза. Ниже — чек-лист того, что стоит проверить перед выкаткой генерации отчётов и выгрузок, с упором на Django 5.x и типовые грабли ORM, памяти и синхронного цикла запрос-ответ.
Статья про подходы и их цену, а не про то, как в принципе сделать экспорт. Форматирование красивого XLSX с формулами и стилями — отдельная тема, здесь смотрим на устойчивость под объём.
Где ломается синхронная выгрузка
Типичный view выглядит так: собрали QuerySet, сложили строки в список, отдали HttpResponse с готовым телом. Три проблемы копятся одновременно.
- Память.
list(queryset)или обходquerysetбез предосторожностей материализует все объекты и держит их в памяти. Полмиллиона строк — это сотни мегабайт на один запрос, и воркер уходит в своп или падает по OOM. - Время. Пока формируется файл, воркер занят. При таймауте Gunicorn (по умолчанию 30 секунд) процесс убивается, а клиент получает оборванный ответ.
- Кратный трафик N+1. Если в строку отчёта попадают поля связанных моделей без
select_related/prefetch_related, на каждую строку летит отдельный SELECT. Полмиллиона строк превращаются в полмиллиона запросов к БД.
Экспорт — это не про то, чтобы отдать данные. Это про то, чтобы отдать их, не заняв воркер на минуту и не выев память под ноль.
Подход 1. Стриминг вместо буфера в памяти
Если файл всё же генерируется в цикле запрос-ответ, отдавайте его через StreamingHttpResponse, а данные тяните итератором. QuerySet по умолчанию кеширует результаты, поэтому для больших выборок используйте .iterator() — он читает курсор порциями и не держит весь результат в памяти.
import csv
from django.http import StreamingHttpResponse
from orders.models import Order
class Echo:
"""Псевдо-буфер: csv.writer пишет в него, а мы сразу отдаём строку."""
def write(self, value):
return value
def export_orders(request):
# select_related убирает N+1 по внешнему ключу customer
queryset = (
Order.objects
.select_related("customer")
.order_by("id")
.values_list("id", "created_at", "customer__email", "total")
)
writer = csv.writer(Echo())
def rows():
yield writer.writerow(["id", "created_at", "email", "total"])
# iterator(chunk_size=...) читает курсор порциями, не кешируя всё
for row in queryset.iterator(chunk_size=2000):
yield writer.writerow(row)
response = StreamingHttpResponse(rows(), content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="orders.csv"'
return responseЧто это даёт: память держится на уровне одного чанка, ответ начинает уходить клиенту сразу, а не после полной сборки файла. Обратите внимание на values_list — он возвращает кортежи вместо инстансов модели, что заметно легче по памяти, когда объекты как таковые вам не нужны.
Чего это не решает: воркер всё равно занят, пока клиент качает файл. Для CSV на несколько сотен тысяч строк это приемлемо; для XLSX с постобработкой — нет, потому что XLSX нельзя стримить построчно так же просто, как CSV.
Подход 2. Отдать генерацию в фоновую задачу
Как только файл собирается дольше нескольких секунд или его формат не поддаётся стримингу, экспорт уезжает из цикла запрос-ответ в Celery (или другой очереди задач). Схема: view ставит задачу и сразу отвечает «отчёт готовится», задача пишет файл в хранилище, пользователь получает ссылку на скачивание.
# tasks.py — Celery 5.x
import csv
from celery import shared_task
from django.core.files.storage import default_storage
from orders.models import Order
@shared_task
def build_orders_report(report_id):
path = f"reports/{report_id}.csv"
# открываем файл в хранилище и пишем порциями
with default_storage.open(path, "w") as f:
writer = csv.writer(f)
writer.writerow(["id", "created_at", "email", "total"])
queryset = (
Order.objects
.select_related("customer")
.order_by("id")
.values_list("id", "created_at", "customer__email", "total")
)
for row in queryset.iterator(chunk_size=2000):
writer.writerow(row)
return pathПлюс: воркеры веб-приложения свободны, генерация не зависит от таймаута Nginx, тяжёлые отчёты не блокируют пользователей. Минус: нужна инфраструктура — брокер (Redis или RabbitMQ), запущенные Celery-воркеры, хранилище для готовых файлов и механизм уведомления пользователя (polling статуса или письмо со ссылкой).
Когда что выбирать
| Критерий | Стриминг в ответе | Фоновая задача |
|---|---|---|
| Объём | до ~100–200 тыс. строк CSV | сотни тысяч и больше, тяжёлый XLSX |
| Время сборки | секунды | десятки секунд и минуты |
| Инфраструктура | ничего сверх Django | брокер, воркеры, хранилище |
| UX | файл качается сразу | «готовится» → ссылка позже |
| Риск таймаута | есть при росте объёма | нет |
Чек-лист перед выкаткой
- Проверьте запросы под реальный объём. Подключите
django-debug-toolbarна стейдже или логируйтеconnection.queriesи убедитесь, что число SELECT не растёт линейно с числом строк. N+1 ловитсяselect_relatedдля ForeignKey/OneToOne иprefetch_relatedдля ManyToMany и обратных связей. - Замерьте память. Прогоните экспорт на копии боевых данных и посмотрите RSS процесса. Если растёт до конца выгрузки — значит, где-то результат материализуется целиком; ищите
list(), срезы и потерянный.iterator(). - Согласуйте таймауты. Таймаут Gunicorn (
--timeout),proxy_read_timeoutв Nginx и время ответа должны быть согласованы. Для стриминга держите соединение живым, для фоновых задач — отвечайте быстро. - Защитите от повторных нажатий. Кнопка «Скачать» без блокировки — это дубли задач. Блокируйте её на клиенте и/или дедуплицируйте задачи по параметрам отчёта.
- Ограничьте параметры. Если пользователь задаёт период или фильтры, валидируйте их: запрос «выгрузить всё за три года без фильтров» должен либо отклоняться, либо уходить в фон.
- Уберите готовые файлы. Отчёты в хранилище копятся. Настройте удаление по TTL, иначе диск закончится молча.
- Кодировка и разделители. Excel на Windows открывает UTF-8 CSV с кракозябрами, если нет BOM, а в русской локали ждёт разделитель «точка с запятой». Решите заранее, под какой инструмент открытия делаете файл.
Что ломается в проде первым
- Таймаут воркера. Симптом — 502/504 на больших выгрузках, в логах Gunicorn «WORKER TIMEOUT». Лечится переходом на фоновую задачу, а не наращиванием таймаута до бесконечности.
- OOM воркера. Процесс исчезает без внятной ошибки в логе приложения, в
dmesg— сообщение от OOM killer. Причина почти всегда — материализация всего результата в памяти. - Дрейф данных при долгой выгрузке. Пока идёт экспорт на полчаса, данные меняются, и файл получается несогласованным. Если важна консистентность — читайте в транзакции с нужным уровнем изоляции или фиксируйте срез по времени в фильтре.
- Курсор и pgbouncer.
.iterator()на PostgreSQL использует серверный курсор; в связке с pgbouncer в режиме transaction pooling это может не работать как ожидается. Проверьте режим пулинга до выката.
Что настроить дальше
Сначала определите порог: до какого объёма выгрузка идёт синхронно, после какого — уходит в очередь. Порог берите из замеров на боевых данных, а не из головы. Затем добавьте на дашборд метрики: время сборки отчёта, длину очереди задач, количество ошибок генерации. Для XLSX-отчётов посмотрите на openpyxl в режиме write_only — он не держит всю книгу в памяти, что важно ровно по той же причине, что и .iterator() для CSV. Точный набор доступных опций сверяйте с документацией вашей версии библиотеки.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Django docs — StreamingHttpResponse, Django docs — QuerySet.iterator()
Частые вопросы
У меня Django 4.2, а не 5.x — код подойдёт?
Стриминг не помогает — воркер всё равно висит и отдаёт 504. Что делать?
Обязательно ли ставить Celery ради выгрузок?
Почему .iterator() иногда не экономит память?
Как отдать XLSX, а не CSV, без раздувания памяти?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.