Guide
Automating Reporting WorkflowsDeep dive

Stream an Excel File from a FastAPI Endpoint

Serve .xlsx from FastAPI without blocking the event loop: StreamingResponse, sync versus async routes, threadpool offload, background jobs and typed error handling.

FastAPI makes an Excel download straightforward, with one decision that matters more than the rest: whether the route is async def or plain def. Building a workbook is CPU-bound work, and doing it inside an async route blocks the event loop for every other request on that worker. This guide gets the route right, returns the file with a StreamingResponse, and covers the background-job route for exports too slow to build inline. It is the FastAPI branch of Serving Excel Files from Python Web Apps.

Where a workbook build runs, by route type A plain def route runs in a threadpool so the event loop keeps serving other requests, while an async def route that builds a workbook inline blocks every other request on the worker. def export(): … async def export(): … event loop accepts the request build runs in a threadpool other requests keep being served event loop accepts the request build runs on the loop itself every other request waits The default — a plain def route — is the correct one for CPU-bound work

Prerequisites

Bash
pip install fastapi "uvicorn[standard]" pandas xlsxwriter openpyxl httpx

httpx is only needed for the test at the end; openpyxl lets that test read the response back.

The working endpoint

Python
"""main.py — an Excel export that does not block the event loop."""
import io
from datetime import date

import pandas as pd
from fastapi import FastAPI
from fastapi.responses import StreamingResponse

app = FastAPI()
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"

def build_workbook(df: pd.DataFrame, sheet: str = "Report") -> io.BytesIO:
    buffer = io.BytesIO()
    with pd.ExcelWriter(buffer, engine="xlsxwriter") as writer:
        df.to_excel(writer, index=False, sheet_name=sheet)
        ws = writer.sheets[sheet]
        ws.freeze_panes(1, 0)
        ws.autofilter(0, 0, len(df), len(df.columns) - 1)
    buffer.seek(0)
    return buffer

@app.get("/exports/sales.xlsx")
def export_sales():                     # deliberately not async
    df = pd.DataFrame({
        "region": ["North", "South", "East", "West"],
        "revenue": [128_400.50, 96_220.00, 51_130.25, 74_905.75],
    })
    filename = f"sales-{date.today():%Y-%m-%d}.xlsx"
    return StreamingResponse(
        build_workbook(df, "Sales"),
        media_type=XLSX,
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )

StreamingResponse reads the buffer in chunks as it writes the response, so the finished bytes are not copied into a second object. The Content-Disposition header is what turns the response into a save rather than a render.

Keep async routes async

If the rest of the handler genuinely awaits things — a database driver, an HTTP client — you want an async def route, and the fix is to push only the CPU-bound build off the loop:

Python
import anyio
from fastapi import FastAPI
from fastapi.responses import StreamingResponse

@app.get("/exports/orders.xlsx")
async def export_orders():
    rows = await db.fetch_orders()                       # genuinely awaitable
    df = pd.DataFrame(rows)
    buffer = await anyio.to_thread.run_sync(build_workbook, df, "Orders")
    return StreamingResponse(
        buffer, media_type=XLSX,
        headers={"Content-Disposition": 'attachment; filename="orders.xlsx"'},
    )

anyio.to_thread.run_sync is the same mechanism FastAPI uses for def routes, made explicit. The rule of thumb: everything that awaits stays on the loop, everything that computes goes to a thread.

Return a typed error before the response begins

Once a streaming response starts, an error cannot be turned into an error page. Build first, respond second:

Python
from fastapi import HTTPException

@app.get("/exports/{report}.xlsx")
def export_report(report: str):
    if report not in {"sales", "orders", "returns"}:
        raise HTTPException(status_code=404, detail="unknown report")
    try:
        df = load_report(report)
    except LookupError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    except Exception as exc:
        raise HTTPException(status_code=500, detail="report generation failed") from exc

    if df.empty:
        df = pd.DataFrame({"message": [f"No rows for {report}."]})
    return StreamingResponse(build_workbook(df, report.title()), media_type=XLSX,
                             headers={"Content-Disposition": f'attachment; filename="{report}.xlsx"'})

Validating the report name against a set — rather than interpolating it into a path or a query — is what keeps the route from becoming a file-read primitive.

Inline export versus queued export A fast report returns the file directly, while a slow one returns a job id, is built by a worker, and is collected from a signed link once ready. Two shapes, chosen by how long the build takes GET /exports build in a thread file in the response enqueue, return id worker builds, stores poll, get A few seconds is the practical dividing line between the two

Queue an export that is too slow to serve inline

FastAPI's BackgroundTasks runs work after the response is sent, which suits a short follow-up job — an audit write, a cache warm — but not a two-minute build the client is waiting on. For that, use a real queue and hand back a job id:

Python
from uuid import uuid4

from fastapi import BackgroundTasks

JOBS: dict[str, dict] = {}

def generate(job_id: str, report: str) -> None:
    try:
        data = build_workbook(load_report(report)).getvalue()
        JOBS[job_id] = {"state": "done", "size": len(data)}
        store_bytes(f"exports/{job_id}.xlsx", data)      # object storage
    except Exception as exc:                              # noqa: BLE001
        JOBS[job_id] = {"state": "failed", "error": str(exc)}

@app.post("/exports/{report}")
def start_export(report: str, tasks: BackgroundTasks):
    job_id = uuid4().hex
    JOBS[job_id] = {"state": "running"}
    tasks.add_task(generate, job_id, report)
    return {"job_id": job_id}

@app.get("/exports/status/{job_id}")
def export_status(job_id: str):
    return JOBS.get(job_id, {"state": "unknown"})

An in-process dictionary is fine for one worker and a demo; in production put the state in Redis or a table so every worker sees it, and store the file where a signed URL can serve it — Upload an Excel report to Amazon S3 with boto3.

Scope, validate and escape

The export is a data-egress endpoint, and FastAPI's dependency system is a natural place to enforce that:

Python
from fastapi import Depends

def current_user(token: str = Depends(oauth2_scheme)):
    user = decode(token)
    if not user:
        raise HTTPException(status_code=401, detail="not authenticated")
    return user

@app.get("/exports/my-orders.xlsx")
def export_my_orders(user=Depends(current_user)):
    df = pd.DataFrame(fetch_orders(owner_id=user.id))    # identity from the token
    df = df.map(lambda v: "'" + v if isinstance(v, str) and v[:1] in "=+-@" else v)
    return StreamingResponse(build_workbook(df, "Orders"), media_type=XLSX,
                             headers={"Content-Disposition": 'attachment; filename="orders.xlsx"'})

The map line neutralises spreadsheet injection: a text value starting with = would otherwise become a live formula in the recipient's Excel.

Test it with the client FastAPI ships

Python
"""test_exports.py"""
import io

import pandas as pd
from fastapi.testclient import TestClient

from main import app

client = TestClient(app)

def test_sales_export():
    resp = client.get("/exports/sales.xlsx")
    assert resp.status_code == 200
    assert resp.headers["content-type"].endswith("spreadsheetml.sheet")
    assert "attachment" in resp.headers["content-disposition"]

    df = pd.read_excel(io.BytesIO(resp.content), engine="openpyxl")
    assert list(df.columns) == ["region", "revenue"]
    assert len(df) == 4

Reading the body back is the assertion that matters: a truncated buffer still returns 200 with correct headers and only fails when something parses it.

Document the endpoint so the schema stays honest

FastAPI generates OpenAPI from type hints, and a route returning a binary file needs to say so — otherwise the schema advertises JSON and every generated client is wrong:

Python
@app.get(
    "/exports/sales.xlsx",
    responses={
        200: {
            "content": {XLSX: {}},
            "description": "An Excel workbook of the current sales summary.",
        },
        404: {"description": "Unknown report"},
    },
    response_class=StreamingResponse,
)
def export_sales():
    ...

Declaring response_class and the responses map costs four lines and makes the download discoverable in the interactive docs, which is often how another team finds it in the first place.

What the response declaration changes Without a response class the generated schema claims a JSON body, while declaring the streaming class and content type produces a schema that describes a spreadsheet download. undeclared declared schema says application/json generated clients parse and fail schema says spreadsheetml.sheet clients save the bytes Four lines of declaration, one fewer integration bug

Send several sheets, and skip the DataFrame when you can

A summary-plus-detail workbook is the same call twice through one writer. And when the data already exists as rows of tuples, going straight to xlsxwriter avoids materialising a DataFrame at all — worth it for a wide export under concurrency:

Python
import io

import xlsxwriter

def workbook_from_rows(header: list[str], rows) -> io.BytesIO:
    buffer = io.BytesIO()
    with xlsxwriter.Workbook(buffer, {"in_memory": True}) as book:
        ws = book.add_worksheet("Report")
        bold = book.add_format({"bold": True})
        ws.write_row(0, 0, header, bold)
        for r, row in enumerate(rows, start=1):
            ws.write_row(r, 0, row)
        ws.freeze_panes(1, 0)
    buffer.seek(0)
    return buffer

{"in_memory": True} keeps xlsxwriter from using temporary files, which matters in a container with a read-only filesystem — a common deployment setting that otherwise produces a confusing permission error at write time.

Common pitfalls and gotchas

  • async def around a synchronous build. The event loop stalls for the whole build; every other request on that worker waits.
  • Returning the buffer without seek(0). The response is empty.
  • Reading the buffer inside the with block. The zip is not finalised until the writer closes.
  • Raising after the response starts. Build the bytes first; a streaming response cannot become an error page.
  • BackgroundTasks for long work. It runs after the response, in the same process — fine for a quick follow-up, wrong for a job the user is waiting on.

Performance and scale notes

Each concurrent export costs roughly the DataFrame plus the finished workbook in memory, and uvicorn's threadpool bounds how many run at once — a useful backpressure mechanism, but one that will queue requests rather than reject them. Decide the cap deliberately: limit inline exports by row count, push the rest to a queue, and set the proxy's read timeout above the slowest inline export you permit. Turn off response buffering for the export path so a large download is not spooled before being forwarded, and do not gzip the spreadsheet content type — an .xlsx is already compressed. For very large single files, xlsxwriter's constant_memory mode is the lever, with the restrictions described in Write a million rows to Excel with xlsxwriter constant memory.

Conclusion

Use a plain def route so FastAPI runs the workbook build in a threadpool, or push the build to a thread explicitly when the rest of the handler is genuinely async. Return the buffer with a StreamingResponse, the spreadsheet media type and an attachment disposition, and validate the report name and the caller before any of it. Anything slower than a few seconds belongs in a queue with a job id, not in a request a proxy is timing.

Frequently asked questions

Should the export route be async def or def? Plain def, unless the whole build is genuinely awaitable. FastAPI runs a def route in a threadpool, so a CPU-bound workbook build does not block the event loop. An async def route that builds a workbook inline stalls every other request on that worker.

Is StreamingResponse better than Response for xlsx? Marginally. StreamingResponse reads the buffer in chunks rather than materialising the bytes again, which helps memory on large files. For a small report either is fine.

Does streaming mean the client gets rows as they are generated? No. An .xlsx is a zip finalised at close, so the workbook must be complete before the first byte is meaningful. Streaming here is about how the finished bytes are sent, not about incremental generation.

How do I show progress for a slow export? Move the build to a background task or a queue, return a job id, and expose a status endpoint the client polls. A progress bar over a single blocking request is not achievable once a proxy timeout is in play.

Can I use openpyxl instead of xlsxwriter here? Yes. Save the workbook to a BytesIO with wb.save(buffer) and return it the same way. xlsxwriter is usually faster for a fresh file; openpyxl is the choice when you are editing an existing one.