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

Клиент нажимает «Выгрузить в Excel», на экране крутится спиннер, через 30 секунд приходит 502 от Nginx, а в логах Gunicorn — WORKER TIMEOUT. Отчёт собирал 180 тысяч строк заказов, держал их все в памяти воркера и рендерил в openpyxl. Пока он работал, воркер не отвечал на другие запросы, а память процесса выросла до пары гигабайт. Разберём, какие подходы к генерации выгрузок в Django (проверялось на Django 4.2 и 5.x) переживают объём, а какие рассыпаются на первой же большой таблице, и по какому критерию выбирать между ними.
Где ломается наивный экспорт
Типичная первая версия выглядит так: собрали list(qs.values(...)), сложили в структуру, отдали через HttpResponse. Проблем сразу три, и все проявляются на объёме.
- Память.
list(queryset)материализует все строки разом. На 200 тысячах записей с десятком полей это сотни мегабайт на объектах Python, и это на каждый одновременный экспорт. - Время внутри воркера. Синхронный запрос блокирует воркер Gunicorn или uWSGI на всё время генерации. Пять пользователей нажали кнопку — пять воркеров заняты, остальной сайт стоит.
- Таймауты. Nginx (
proxy_read_timeout) и Gunicorn (--timeout) по умолчанию рвут соединение раньше, чем большой отчёт досчитается.
Дальше — четыре подхода, каждый закрывает часть этих проблем ценой своих компромиссов.
Подход 1: StreamingHttpResponse + CSV
CSV не требует держать весь файл в памяти: строки пишутся по одной. В связке со StreamingHttpResponse и итерацией по queryset.iterator() вы не материализуете таблицу целиком.
import csv
from django.http import StreamingHttpResponse
# Псевдо-файл: csv.writer пишет в него, а он сразу возвращает строку
class Echo:
def write(self, value):
return value
def export_orders_csv(request):
writer = csv.writer(Echo())
def rows():
# Заголовок
yield writer.writerow(["id", "created", "total", "status"])
# iterator() не кеширует QuerySet и тянет данные порциями
qs = Order.objects.values_list("id", "created", "total", "status")
for row in qs.iterator(chunk_size=2000):
yield writer.writerow(row)
response = StreamingHttpResponse(rows(), content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="orders.csv"'
return response
Память под контролем: в каждый момент в памяти держится chunk_size строк, а не весь результат. Параметр chunk_size у .iterator() появился в Django 2.0. Но воркер по-прежнему занят на всё время отдачи, а таймаут Nginx считает не общее время, а паузу между отправленными байтами — со стримом байты идут постоянно, поэтому proxy_read_timeout обычно не срабатывает. Ограничение CSV: нет форматирования, типов и листов. Excel открывает UTF-8 CSV с кириллицей криво без BOM — добавьте \ufeff в начало или отдавайте text/csv; charset=utf-8 с BOM.
Подход 2: openpyxl в write-only режиме
Когда нужен именно XLSX с форматированием, обычный режим openpyxl держит всю книгу в памяти. У библиотеки есть write-only режим, который сбрасывает строки на диск по мере записи.
from openpyxl import Workbook
from openpyxl.cell import WriteOnlyCell
# openpyxl 3.x. write_only=True — строки не хранятся в памяти целиком
wb = Workbook(write_only=True)
ws = wb.create_sheet("Orders")
ws.append(["id", "created", "total", "status"])
for row in Order.objects.values_list(
"id", "created", "total", "status"
).iterator(chunk_size=2000):
ws.append(list(row))
wb.save("/tmp/orders.xlsx") # или в BytesIO, если файл небольшой
Write-only режим снимает проблему памяти на объёме, но накладывает ограничения: нельзя вернуться к уже записанной ячейке, нельзя читать книгу, стили задаются через WriteOnlyCell. XLSX всё равно тяжелее и медленнее CSV, и на действительно больших выгрузках (миллионы строк) сам формат становится обузой — Excel такие файлы открывает с трудом.
Правило, которое экономит часы отладки: если выгрузка может занять больше нескольких секунд, она не должна выполняться внутри HTTP-запроса. Пользователь получает не файл, а ссылку «отчёт готовится».
Подход 3: фоновая задача через Celery
Это единственный подход, который снимает блокировку воркера. Запрос ставит задачу в очередь и сразу отвечает, файл генерируется в отдельном процессе Celery, результат кладётся в хранилище (S3, MinIO, локальный MEDIA_ROOT), пользователь скачивает по готовой ссылке.
# tasks.py — Celery 5.x
from celery import shared_task
from django.core.files.base import ContentFile
import csv, io
@shared_task
def build_orders_report(report_id):
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(["id", "created", "total", "status"])
for row in Order.objects.values_list(
"id", "created", "total", "status"
).iterator(chunk_size=5000):
writer.writerow(row)
report = Report.objects.get(pk=report_id)
report.file.save(f"orders_{report_id}.csv",
ContentFile(buffer.getvalue().encode("utf-8")))
report.status = "done"
report.save(update_fields=["status", "file"])
Здесь StringIO уместен, потому что процесс Celery не держит HTTP-соединение и таймаут воркера ему не грозит. Но на огромных отчётах даже фоновый процесс не стоит заставлять держать весь файл в памяти — пишите порциями во временный файл на диске, а потом загружайте его в хранилище. Цена подхода: нужен брокер (Redis или RabbitMQ), нужна модель Report со статусом, и на фронте — опрос статуса или уведомление. Это заметно больше кода, чем один view.
Подход 4: выгрузка на стороне БД
Если данные не требуют обработки в Python, PostgreSQL умеет отдавать CSV сам через COPY ... TO STDOUT. Это быстрее, чем гонять строки через ORM, потому что нет создания Python-объектов.
import psycopg2 # или через connection.cursor() Django
from django.db import connection
def export_via_copy(file_obj):
sql = """COPY (
SELECT id, created, total, status FROM orders_order
WHERE status = 'paid'
) TO STDOUT WITH CSV HEADER"""
with connection.cursor() as cursor:
# copy_expert доступен у psycopg2-курсора
cursor.copy_expert(sql, file_obj)
Синтаксис copy_expert относится к psycopg2. В psycopg3 API работы с COPY другой — сверьтесь с документацией psycopg3, прежде чем переносить код. Ограничение очевидно: вы завязаны на конкретную СУБД и обходите ORM, значит теряете фильтры прав доступа и любую бизнес-логику, которая живёт в Python.
Что выбирать
| Подход | Объём | Блокирует воркер | Формат | Сложность |
|---|---|---|---|---|
| StreamingHttpResponse + CSV | до сотен тысяч строк | Да, на всё время | только CSV | низкая |
| openpyxl write-only | десятки тысяч строк | Да | XLSX с форматированием | средняя |
| Celery + хранилище | любой | Нет | любой | высокая |
| COPY на стороне БД | миллионы строк | Да, но быстро | CSV | средняя, привязка к СУБД |
Понятный критерий выбора:
- Отчёт стабильно считается меньше 5 секунд и нужен CSV — StreamingHttpResponse, самый дешёвый вариант.
- Нужен Excel с форматами и объём умеренный — openpyxl write-only прямо в ответе.
- Время генерации непредсказуемо или растёт с базой — только Celery. Не пытайтесь «пока обойтись синхронно»: база растёт, а таймаут не двигается.
- Сырая выгрузка без бизнес-логики на миллионы строк — COPY, но проверьте, что права доступа отфильтрованы в самом SQL.
Что ломается чаще всего
- Забыли
.iterator(). Обычный проход по QuerySet кеширует все объекты — память утекает, даже если пишете построчно. - N+1 в цикле экспорта. Обращение к
order.user.nameвнутри цикла на 100 тысяч строк — это 100 тысяч запросов. Используйтеvalues_listилиselect_related. - Кириллица в CSV. Без BOM Excel показывает кракозябры. Добавьте
\ufeffв начало файла. - Nginx рвёт соединение. Если между байтами ответа пауза больше
proxy_read_timeout, соединение закроется. Стрим отправляет байты постоянно, но при тяжёлом COPY внутри одного запроса паузы возможны. - Celery-задача без ограничения времени. Зависший запрос к БД съест воркер Celery навсегда. Ставьте
time_limitу задачи.
Что настроить дальше
Инвалидация и кеширование готовых отчётов — отдельная тема, здесь мы её не разворачивали. Ближайшие шаги после того, как выгрузка заработала: поднимите --timeout Gunicorn для синхронных экспортов или, лучше, уведите их в Celery; замерьте пик памяти воркера под реальным объёмом (htop, memory_profiler) и сравните с наивной версией; проверьте на объёме, близком к продовому, а не на трёх тестовых заказах. Показатель, за которым стоит следить в проде, — время ответа на эндпоинте экспорта и количество занятых воркеров в момент массовых выгрузок.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: Django: QuerySet.iterator() и StreamingHttpResponse, openpyxl: Optimised modes (write-only)
Частые вопросы
У меня не PostgreSQL, а MySQL — подход с COPY подойдёт?
Стрим работает, но Nginx всё равно возвращает 504. Что не так?
Работает на Django 3.2 — код из статьи подойдёт?
Отчёт всё равно съедает память, хотя я использую .iterator(). Почему?
Обязательно ли поднимать Celery ради экспорта, или можно обойтись?
Как отдать пользователю уведомление, что фоновый отчёт готов?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.