2026, Jan 10 09:03

Экспорт CSV в Flask потоково: миллионы строк без переполнения памяти

Как сделать потоковый экспорт CSV в Flask без роста памяти: генератор, маленький буфер StringIO, stream_with_context и заголовок Content-Disposition. Надёжно.

Выгрузка CSV с веб‑эндпоинта кажется простой задачей, пока объём данных не достигает миллионов строк. Наивный подход, который собирает весь файл в памяти, рано или поздно приведёт к падению процесса. Рабочее решение — передавать CSV построчно в режиме постоянного (ограниченного) потребления памяти.

Проблема

Приведённый ниже эндпоинт сначала полностью собирает CSV в памяти, а уже потом отправляет клиенту. Для небольших выборок это срабатывает, но при миллионах строк процесс упирается в объём ОЗУ и падает.

import io, csv
from flask import Flask, Response
svc = Flask(__name__)
def fetch_rows():
    # представьте здесь 5 миллионов строк из БД
    for n in range(5_000_000):
        yield (n, f"name-{n}", n % 100)
@svc.get("/export")
def export_csv_plain():
    row_iter = fetch_rows()
    mem_io = io.StringIO()
    csvw = csv.writer(mem_io)
    csvw.writerow(["id", "name", "score"])
    csvw.writerows(row_iter)
    mem_io.seek(0)
    return Response(
        mem_io.getvalue(),
        mimetype="text/csv",
        headers={"Content-Disposition": "attachment; filename=report.csv"}
    )

Что идёт не так и почему

Код записывает каждую строку в единый буфер в памяти, прежде чем что‑то отдать клиенту. Для крупных отчётов этот буфер разрастается, пока процесс не исчерпает память. Хотя сами данные можно получать потоком, этап сборки CSV нивелирует это преимущество, материализуя весь файл целиком.

Решение: потоковая отдача с крошечным переиспользуемым буфером

Вместо накопления всего файла записывайте данные в небольшой буфер в памяти, отдавайте его содержимое клиенту, затем очищайте буфер и продолжайте. Такой подход держит потребление памяти на одном уровне независимо от количества строк. Ответ превращается в поток с помощью генератора, обёрнутого в stream_with_context. В заголовках указывается Content-Disposition, чтобы браузер предложил сохранить файл.

from flask import Flask, Response, stream_with_context
import csv, io
svc = Flask(__name__)
def fetch_rows():
    for n in range(5_000_000):
        yield n, f"name-{n}", n % 100
@svc.get("/export")
def export_csv_stream():
    def chunked():
        stash = io.StringIO()
        cw = csv.writer(stash)
        cw.writerow(("id", "name", "score"))
        yield stash.getvalue()
        stash.seek(0)
        stash.truncate(0)
        for rec in fetch_rows():
            cw.writerow(rec)
            yield stash.getvalue()
            stash.seek(0)
            stash.truncate(0)
    hdr_map = {
        "Content-Disposition": "attachment; filename=report.csv",
        "X-Accel-Buffering": "no",
    }
    return Response(
        stream_with_context(chunked()),
        mimetype="text/csv",
        headers=hdr_map,
        direct_passthrough=True,
    )

Почему это работает

Генератор записывает заголовок, отдаёт его, затем обрабатывает каждую строку и сразу же выдаёт содержимое буфера. После каждого yield буфер перематывается с помощью seek(0) и очищается через truncate(0). Повторное использование маленького StringIO в цикле не даёт потреблению памяти расти, сколько бы строк ни стримилось. Заголовок Content-Disposition заставляет браузер воспринимать ответ как файл для скачивания.

Зачем это важно

Отчёты со временем разрастаются. Архитектура, которая на ранних этапах казалась нормальной, в продакшене начинает падать, как только объём данных достигает миллионов записей. Потоковая отдача CSV делает потребление памяти предсказуемым и позволяет клиенту получать данные сразу.

Вывод

Экспортируя CSV из эндпоинта Flask, не собирайте весь файл в памяти. Передавайте его построчно через генератор, переиспользуйте небольшой буфер, применяя seek и truncate после каждого yield, и выставляйте корректные заголовки для скачивания. Такой подход хорошо масштабируется и сохраняет стабильность сервиса даже при очень больших выгрузках.