Прямой ответ
Три развилки определяют архитектуру экспорта. Формат: Excel (данные для дальнейшей работы пользователя — openpyxl для чтения/правок, xlsxwriter для чистой записи больших файлов — быстрее и экономнее по памяти, стили/формулы/графики; CSV — для данных без оформления — выбор формата — помня про BOM для Excel-совместимости кириллицы) против PDF (документ для людей/печати/подписи — WeasyPrint: HTML+CSS → PDF — вёрстка знакомыми инструментами, пагинация/колонтитулы из CSS; альтернативы reportlab — программная рисовка — для счетов-пачек с жёстким шаблоном, playwright-pdf — когда PDF должен совпадать с веб-страницей пиксель в пиксель). Синхронность: выгрузка до ~10–30 с — синхронный ответ (Content-Disposition: attachment — стримингом StreamingResponse, не накапливая в памяти); больше — асинхронный паттерн (запрос → задача в очереди (Celery) → файл в хранилище (MinIO/S3) → уведомление/ссылка с истечением — «кнопка экспорта зависла на две минуты и отвалилась по таймауту прокси» — стандартная смерть синхронных тяжёлых выгрузок). Объём: тысячи строк — целиком в память ок; сотни тысяч — генераторы (xlsxwriter write_row по курсору БД без pandas-DataFrame целиком; CSV-поток построчно), server-side курсор в БД (не fetchall() миллиона строк — запросы: yield_per/stream_results), лимиты и честные границы («экспорт до 500k строк, больше — по сегментам» — лучше явного OOM). Ошибка №1 жанра: экспорт в веб-воркере с таймаутом 30 с на данных, которые растут — закладывать асинхронность сразу, даже если «сейчас-то быстро».
Excel/CSV: openpyxl, xlsxwriter, потоки
xlsxwriter (запись): workbook → worksheet → write_row по итератору данных; constant_memory=True — режим последовательной записи (строки не держатся в памяти — обязательность для больших; ограничение — нельзя вернуться назад по строкам); стили один раз объектом (header_fmt = wb.add_format({...}) — создание формата в цикле на каждую ячейку — раздувание файла и памяти — классическая ошибка), freeze panes/autofilter/ширины колонок — удобство пользователя (отчёт без закреплённой шапки и фильтра — не отчёт), формулы — write_formula (Excel пересчитает при открытии; значения-результаты для viewers без пересчёта — опционально). openpyxl — когда нужно читать/модифицировать существующие (шаблон с логотипом — загрузка template, дозаполнение; read_only=True для чтения больших; write_only=True — свой потоковый режим записи). CSV — csv.writer поверх стриминг-ответа; utf-8-sig (BOM) — иначе Excel откроет кириллицу кракозябрами (вечный тикет); разделитель ; для ru-Excel (локальные списки разделителей — запятая десятичная); кавычки/переносы — csv-модуль решает (ручная склейка строк — инъекция формул — =cmd|... в ячейке — санитизация значений, начинающихся с =+-@ — CSV-injection, реальная уязвимость). Память: DataFrame на 1M строк + to_excel — гигабайты RAM (pandas-Excel-запись через openpyxl — вдвойне; курсор БД → write_row — мегабайты); тест экспорта на объёме ×10 от текущего прода — обязательная проверка перед релизом.
PDF: HTML-шаблон против программной рисовки
WeasyPrint (HTML+CSS → PDF): шаблон — Jinja2 (данные → HTML — шаблонизация) → HTML(string=html).write_pdf(); CSS @page — размер/поля/колонтитулы с нумерацией (@bottom-center { content: counter(page) "/" counter(pages); }), page-break-правила (break-inside: avoid для строк таблиц/блоков — «строка таблицы разрезана пополам между страницами» — лечится CSS, не кодом), шрифты — подключить явно (кириллица без подключённого шрифта — квадратики — font-face на DejaVu/PT; системные шрифты в контейнере — установить пакеты — docker-слои с шрифтами — частая пропущенная деталь); производительность — сотни страниц ок, но тяжёлый CSS/графика замедляют — тест на реальном объёме. reportlab — когда PDF генерируется «как принтер» (счета пачками с фиксированной сеткой — платоны/platypus-фреймы; скорость выше, контроль абсолютный; цена — вёрстка кодом, правка шаблона — релиз кода); playwright/puppeteer print-to-pdf — «PDF = скриншот веб-страницы» (полное совпадение с UI; цена — headless-браузер в зависимостях — тяжело в контейнере/памяти — оправдано для сложных интерактивных отчётов, не для счетов). Выбор: отчёты с таблицами/итоговыми — WeasyPrint (HTML-навыки команды, правки быстрые); пачки одинаковых документов — reportlab; pixel-perfect копия UI — playwright.
Асинхронный конвейер больших экспортов
Схема прода: запрос — POST /reports (параметры + валидация — Pydantic) → запись report(id, status=queued, owner) → задача в очереди → 202 Accepted с id (не блокировать воркер); задача: серверный курсор по данным → потоковая запись файла во временный → загрузка в S3/MinIO (multipart для больших) → status=ready, url (presigned с истечением 24–72 ч — файлы отчётов не вечны); отдача: поллинг статуса клиентом/WebSocket-уведомление (realtime) → скачивание по presigned (не через приложение — гигабайты не должны течь через веб-воркеры). Детали, которые отличают работающее от падающего: идемпотентность (повторный клик «Экспорт» — не вторая задача на те же параметры, а возврат существующей — идемпотентность); таймауты/лимиты задачи (Celery-дисциплина: time_limit, отдельная очередь reports — тяжёлые выгрузки не давят лёгкие задачи); наблюдаемость (метрики: длительность генерации по типам отчётов, доля failed, размер файлов — p95 выгрузки растёт задолго до жалоб — метрики); чистка (TTL на файлы в бакете — lifecycle-правило S3 — «бакет отчётов» без TTL — 800 ГБ через год); права (presigned-ссылка на чужой отчёт — проверка владельца при генерации URL, не только при запросе экспорта — горизонтальный доступ к чужим данным через утёкшую ссылку — OWASP-мышление); данные на момент экспорта — снапшот-семантика (отчёт, генерируемый 20 минут, видит меняющиеся данные — либо repeatable read, либо «данные на T0» в шапке отчёта — честность к пользователю).
Практический сценарий: экспорт заказов от кнопки к конвейеру
Исходник: «Скачать все заказы» — синхронный pandas → xlsx в веб-запросе; на 5k строк — 25 с (прокси обрывает на 30 с — половина экспортов падает), RAM веб-воркера +700 МБ на пике. Рефакторинг: (1) асинхронная схема — POST /reports → Celery(reports-очередь) → 202; (2) генерация — серверный курсор SQLAlchemy → xlsxwriter constant_memory (без pandas вовсе — DataFrame не строится); RAM задачи — 40 МБ стабильно на 500k строк (проверено на синтетике ×100 от прода); (3) файл — MinIO, presigned 48 ч, lifecycle-удаление 30 дней; статус — поллинг + письмо по готовности; (4) мелочи жанра: BOM-CSV добавлен вторым форматом (бухгалтерия просила), freeze panes + autofilter в xlsx, =-префиксы экранированы (тест «заказ с названием =SUM(A1)» — ячейка безопасна), идемпотентность «повторный клик — тот же отчёт» (проверено двойным POST); (5) наблюдаемость: метрика длительности — через месяц p95 вырос с 40 с до 3 мин (сезонный рост заказов) — алерт сработал, добавлен воркер reports-очереди — пользователи не заметили. Итог: 500k строк — 4 минуты фоном, ноль упавших экспортов за квартал, веб-воркеры разгружены; PDF-акты тем же конвейером (WeasyPrint-шаблон) — добавлены за день.
FAQ
Почему Excel-файл «битый» или с кракозябрами? Частые причины: CSV без BOM (utf-8-sig — для кириллицы в Excel), оборванная генерация (задача убита по таймауту — файл неполный — писать во временный и переименовывать/загружать атомарно), «xlsx» на самом деле HTML-таблица с расширением .xlsx (легаси-трюк — Excel ругается — генерировать настоящим openpyxl/xlsxwriter), повреждённые символы из БД (нулевые байты в данных — openpyxl падает — санитизация ввода при записи).
Как делать отчёты с графиками?
Excel — родные графики xlsxwriter (add_chart — данные в файле, график интерактивен — правильный выбор для аналитических выгрузок); PDF — изображение графика (matplotlib → PNG в память → вставка в HTML-шаблон — визуализация); «график в CSV» — бессмысленно (данные есть, визуализация — на стороне потребителя); интерактивность — не в файл, а в дашборд (Grafana — когда отчёт смотрят, а не скачивают).
Нужен ли отдельный сервис генерации отчётов? До десятков типов отчётов и умеренных объёмов — нет (очередь + воркеры в общем приложении — изоляция отдельной reports-очередью достаточна); отдельный сервис оправдан при: тяжёлых зависимостях (браузер для playwright-pdf не хочется в основном образе — docker-разделение), разных языках генерации, регуляторных требованиях изоляции; преждевременный микросервис отчётов — лишний hop и деплой без выигрыша (monorepo/сервисы — та же логика границ).
Чем отдавать файл пользователю: через приложение или ссылкой? Маленькие (<10 МБ) синхронные — через приложение (StreamingResponse — память не растёт); большие/асинхронные — presigned-ссылкой на хранилище (трафик мимо веб-слоя, CDN-совместимо, истечение встроено); «отдаём гигабайт через Django-вьюху» — воркер занят на минуты, память/буферы прокси — антипаттерн; промежуточный вариант — nginx X-Accel-Redirect (приложение авторизует, nginx отдаёт файл с диска — для self-hosted без S3 — nginx-практика).
Источники: документация WeasyPrint, openpyxl, xlsxwriter — ссылки в шапке.