Экспорт данных в Django: рабочие приёмы и их цена
Кнопка «Скачать CSV» на 500 000 строк кладёт воркер в таймаут. Разбираем, как отдавать выгрузки в Django без OOM: стриминг, генераторы, values() и вынос тяжёлых экспортов в очередь.

Задача звучит просто: пользователь жмёт «Экспорт», получает CSV или Excel. На тысяче строк работает любой код. На полумиллионе строк наивная реализация загружает весь QuerySet в память, воркер Gunicorn упирается в лимит времени, Nginx отдаёт 504, а если объектов много и они «жирные» — процесс убивает OOM killer. Разберём приёмы для Django 4.2 LTS и 5.x: чем платит каждый и когда какой выбирать.
Почему наивный экспорт падает
Типичный первый вариант выглядит так: собрать список словарей, отдать HttpResponse. Проблема в трёх местах сразу.
- QuerySet материализуется целиком.
list(Order.objects.all())тянет все строки и создаёт по объекту модели на каждую. Модель Django — не бесплатная структура: дескрипторы полей, кеш связанных объектов. На 500 тысячах строк это сотни мегабайт. - Ответ формируется в памяти до отправки. Обычный
HttpResponseдержит всё тело доreturn. Пока файл не собран целиком, клиент не получает ни байта. - Всё это внутри одного HTTP-запроса. У воркера есть таймаут (по умолчанию у Gunicorn 30 секунд). Тяжёлая выгрузка в него не влезает.
Дальше — как чинить, от самого простого к самому надёжному.
Приём 1. StreamingHttpResponse + генератор
Первое, что убирает пик памяти по ответу — стриминг. StreamingHttpResponse принимает итератор и отдаёт тело порциями, не собирая его целиком. Ключевой момент — csv.writer хочет объект с методом write, а нам нужен генератор строк. Решается классом-псевдобуфером.
import csv
from django.http import StreamingHttpResponse
class Echo:
"""Отдаёт то, что в него пишут, вместо накопления в буфере."""
def write(self, value):
return value
def export_orders(request):
# iterator() не кеширует QuerySet — объекты не держатся в памяти пачкой
queryset = Order.objects.all().iterator(chunk_size=2000)
writer = csv.writer(Echo())
def rows():
yield writer.writerow(["id", "created", "total"])
for order in queryset:
yield writer.writerow([order.id, order.created, order.total])
response = StreamingHttpResponse(rows(), content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="orders.csv"'
return response
Здесь работают два рычага: StreamingHttpResponse не копит тело, а .iterator(chunk_size=...) заставляет ORM тянуть строки порциями и не кешировать QuerySet. Без iterator() стриминг ответа не спасёт — QuerySet всё равно материализуется целиком при первом проходе.
Убираем накладные расходы на объекты модели
Даже с iterator() вы создаёте по полноценному объекту Order на строку. Если вам нужны только значения полей — берите их напрямую через .values() или .values_list(). Возвращаются словари и кортежи, а не экземпляры модели.
queryset = (
Order.objects
.values_list("id", "created", "total") # кортежи вместо объектов
.iterator(chunk_size=2000)
)
Разница на больших выгрузках заметная: пропадают построение объекта, дескрипторы полей и потенциальные ленивые обращения к связанным таблицам. Если связанные поля всё же нужны — добавляйте select_related() для ForeignKey заранее, иначе на каждой строке словите отдельный запрос (проблема N+1).
Стриминг решает память по ответу, но не решает время. Если выгрузка честно считается три минуты, стриминг просто растянет 504 на три минуты — таймаут воркера от этого не исчезнет.
Приём 2. server-side cursor на больших таблицах
По умолчанию драйвер PostgreSQL при выполнении запроса пересылает клиенту весь результат сразу. .iterator() экономит память Python, но результат уже прилетел на уровне драйвера. Для действительно больших наборов на PostgreSQL включают серверный курсор: база отдаёт строки порциями по запросу.
В Django это включается тем же chunk_size у iterator() — начиная с версии, где появилась поддержка серверных курсоров для psycopg. Точное поведение зависит от версии Django и драйвера (psycopg2 против psycopg 3), поэтому сверьтесь с разделом про iterator() в документации QuerySet для вашей версии, прежде чем закладываться на серверный курсор в проде.
Приём 3. Тяжёлый экспорт — в фоновую задачу
Как только выгрузка перестаёт укладываться в разумный таймаут запроса, её место — не в HTTP-цикле, а в очереди. Схема стандартная: запрос ставит задачу, задача пишет файл в хранилище, пользователь получает ссылку на скачивание.
- Пользователь жмёт «Экспорт» — вью создаёт запись о задаче и ставит её в очередь (Celery, RQ, Django-Q — что уже есть в проекте).
- Воркер формирует файл, пишет его во временное хранилище или S3-совместимое хранилище, обновляет статус задачи.
- Фронт опрашивает статус или получает уведомление; по готовности показывает ссылку.
Да, это отдельная инфраструктура и код статусов. Но это единственный способ отдавать выгрузки на миллионы строк без тюнинга таймаутов Gunicorn и Nginx под каждый отчёт.
Excel: отдельная боль
CSV стримится строками — Excel так нельзя. Формат XLSX — это ZIP-контейнер, который стандартными библиотеками (например, openpyxl) собирается в памяти или во временном файле целиком. Стриминг «строка за строкой» здесь не работает без специальных режимов.
Практическое правило: если пользователю нужен именно Excel и строк много — отдавайте CSV (Excel его открывает), либо генерируйте XLSX в фоновой задаче во временный файл на диске, а не в оперативной памяти воркера. У openpyxl есть режим потоковой записи через write_only — он снижает пик памяти, но проверьте его ограничения в документации библиотеки под вашу версию, там нельзя произвольно возвращаться к уже записанным ячейкам.
Что выбрать под задачу
| Подход | Когда подходит | Чем платите |
|---|---|---|
| HttpResponse + list() | До нескольких тысяч строк, редкий экспорт | Падает по памяти и времени при росте данных |
| StreamingHttpResponse + iterator() | Десятки–сотни тысяч строк, укладывается в таймаут | Держит соединение открытым всё время выгрузки |
| + values_list() | Нужны только поля, без методов модели | Теряете удобство объектов, ручной контроль N+1 |
| Фоновая задача + файл | Миллионы строк, тяжёлые отчёты, Excel | Очередь, хранилище, код статусов и UI ожидания |
Типичные ошибки
- Стриминг без iterator(). Генератор отдаёт строки порциями, но QuerySet материализуется целиком при первом проходе — память не экономится.
- N+1 внутри цикла экспорта. Обращение к
order.customer.nameбезselect_related("customer")добавляет по запросу на строку. На большой выгрузке это тысячи лишних запросов. - Открытая транзакция на всё время выгрузки. Если экспорт обёрнут в длинную транзакцию, вы держите её открытой минутами и мешаете другим. Экспорт — операция чтения, длинная транзакция ему не нужна.
- Кодировка для Excel. Русский CSV в UTF-8 без BOM Excel открывает крякозябрами. Добавьте BOM (
\ufeff) первой строкой или отдавайте в кодировке, которую ждёт целевой Excel.
Что настроить дальше
Замеряйте пик памяти воркера и время ответа на реальном объёме, а не на десяти тестовых строках — проблема проявляется только на масштабе. Смотрите на количество SQL-запросов при экспорте через django-debug-toolbar или логирование запросов: рост числа запросов с ростом строк — верный признак N+1. Если выгрузки регулярно перешагивают таймаут — не крутите таймауты вверх, выносите экспорт в очередь. Инвалидация и повторное использование уже сгенерированных файлов — отдельная тема, здесь мы её не разбирали.
Генератор изображений: Иллюстрация, обложка, баннер, схема — по текстовому описанию, без исходного фото.
Спросить за 20 ₽Источники: Django QuerySet API — iterator(), Django — StreamingHttpResponse
Частые вопросы
У меня Django 4.2, а не 5.x — приёмы сработают?
Стриминг включил, а память всё равно течёт — почему?
Можно ли стримить XLSX так же, как CSV?
Что делать, если экспорт всё равно не укладывается в таймаут?
Кириллица в CSV открывается крякозябрами в Excel — как исправить?
iterator() с chunk_size всегда лучше обычного QuerySet для экспорта?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.