Stream an Excel File from a FastAPI Endpoint
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.
Prerequisites
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
"""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:
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:
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.
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:
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:
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
"""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:
@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.
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:
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 defaround 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
withblock. 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.
BackgroundTasksfor 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.
Related
- Up: Serving Excel Files from Python Web Apps — headers, security and background jobs across frameworks.
- Return an Excel file from a Flask download endpoint — the same endpoint without the async question.
- Build an Excel workbook in memory with BytesIO — why closing and rewinding the buffer are not optional.
- Retry a failed Excel report job in Python — making the queued path resilient.
- Upload an Excel report to Amazon S3 with boto3 — where a background-built workbook should land.