Serving Excel Files from Python Web Apps
Sooner or later a Python web app needs an "Export to Excel" button. The mechanics are not hard, but the details decide whether it works under load: build the workbook in memory rather than on disk, send the right two headers, keep slow reports out of the request cycle, and make sure the export cannot be pointed at somebody else's data. This topic covers the pattern in Flask, FastAPI and Django, plus the background-job approach for reports too slow to generate inline. It extends Automating Reporting Workflows from scheduled delivery into on-demand delivery.
Build the workbook in memory
Every framework's answer starts the same way: write into a buffer, not a file. io.BytesIO behaves like a file object, so both pandas and xlsxwriter accept it directly:
"""Return an .xlsx as bytes, with no temporary file anywhere."""
import io
import pandas as pd
def build_workbook(df: pd.DataFrame) -> bytes:
buffer = io.BytesIO()
with pd.ExcelWriter(buffer, engine="xlsxwriter") as writer:
df.to_excel(writer, sheet_name="Report", index=False)
sheet = writer.sheets["Report"]
sheet.freeze_panes(1, 0)
sheet.autofit()
buffer.seek(0)
return buffer.getvalue()
Two details are load-bearing. The with block closes the writer, which is when xlsxwriter actually finalises the zip — return the buffer before that and you ship a truncated file. And seek(0) rewinds the buffer so the whole workbook is read, not the zero bytes after the write position. The full mechanics are in Build an Excel workbook in memory with BytesIO.
Send the right headers
A download is defined by two headers. Get either wrong and the browser renders binary noise or saves a file Excel refuses to open:
| Header | Value |
|---|---|
Content-Type | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
Content-Disposition | attachment; filename="sales_2026-08.xlsx" |
Add Content-Length when you have the bytes in hand — browsers show a progress bar with it and cannot without. For non-ASCII filenames use the RFC 5987 form, filename*=UTF-8''…, since a raw Unicode filename in the plain parameter is not portable.
Flask
Flask's send_file does the header work when you give it a buffer and a name:
import io
from flask import Flask, send_file
import pandas as pd
app = Flask(__name__)
@app.get("/exports/sales.xlsx")
def export_sales():
df = pd.DataFrame({"region": ["North", "South"], "revenue": [128_400.5, 96_220.0]})
buffer = io.BytesIO()
with pd.ExcelWriter(buffer, engine="xlsxwriter") as writer:
df.to_excel(writer, index=False, sheet_name="Sales")
buffer.seek(0)
return send_file(
buffer,
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
as_attachment=True,
download_name="sales.xlsx",
)
The parameter is download_name in Flask 2.0 and later; older code uses attachment_filename. The complete version, including error handling and dated filenames, is in Return an Excel file from a Flask download endpoint.
FastAPI
FastAPI returns a Response with explicit headers, or a StreamingResponse when you would rather not hold the whole file in one object twice:
import io
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import pandas as pd
app = FastAPI()
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
@app.get("/exports/sales.xlsx")
def export_sales():
df = pd.DataFrame({"region": ["North", "South"], "revenue": [128_400.5, 96_220.0]})
buffer = io.BytesIO()
with pd.ExcelWriter(buffer, engine="xlsxwriter") as writer:
df.to_excel(writer, index=False, sheet_name="Sales")
buffer.seek(0)
return StreamingResponse(
buffer,
media_type=XLSX,
headers={"Content-Disposition": 'attachment; filename="sales.xlsx"'},
)
Because the endpoint is synchronous, FastAPI runs it in a threadpool and the event loop stays free — which is exactly what you want for a CPU-bound workbook build. Declaring it async def and doing the work inline would block every other request on the worker. That trap and the async-safe alternative are covered in Stream an Excel file from a FastAPI endpoint.
Django
Django's HttpResponse takes the bytes and the headers directly, and a queryset converts to a DataFrame in one call:
import io
import pandas as pd
from django.http import HttpResponse
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
def export_orders(request):
rows = (Order.objects
.filter(owner=request.user) # scope to the caller
.values("reference", "region", "total"))
df = pd.DataFrame.from_records(rows)
buffer = io.BytesIO()
with pd.ExcelWriter(buffer, engine="xlsxwriter") as writer:
df.to_excel(writer, index=False, sheet_name="Orders")
buffer.seek(0)
response = HttpResponse(buffer.getvalue(), content_type=XLSX)
response["Content-Disposition"] = 'attachment; filename="orders.xlsx"'
return response
.values(...) returns dictionaries rather than model instances, which avoids materialising objects you only want columns from. The full treatment — related fields, chunked iteration, formatting and permissions — is in Export a Django queryset to Excel.
Keep slow reports out of the request
Anything past a few seconds does not belong inline. A proxy will time out, a user will double-click, and two identical builds will run at once. Queue it instead:
"""Enqueue, then let the client collect the finished file."""
from celery import shared_task
@shared_task
def build_export(user_id: int, period: str) -> str:
data = fetch_rows(user_id, period)
key = f"exports/{user_id}/{period}.xlsx"
upload_bytes(key, build_workbook(data)) # to S3 or equivalent
return key
The endpoint returns a job id; a status endpoint reports progress; the finished file is served from object storage with a short-lived signed URL. That also gives retries for free — see Retry a failed Excel report job in Python and Upload an Excel report to Amazon S3 with boto3.
Make the export safe
An export endpoint is a data-egress endpoint, so it deserves the same scrutiny as any other:
- Scope every query to the authenticated user. Filters may come from the request; the identity must not.
- Never build a path from user input. Generate filenames; do not echo a parameter into one.
- Cap the row count. An unbounded export is a denial-of-service vector as well as a memory risk. Refuse politely above a limit and offer the background route.
- Escape leading formula characters in text cells. A value beginning
=,+,-or@becomes a live formula when the file is opened — the spreadsheet injection problem. - Log who exported what. An export is a copy of your data leaving the system.
def escape_formula(value):
"""Neutralise a cell that Excel would otherwise treat as a formula."""
if isinstance(value, str) and value[:1] in ("=", "+", "-", "@"):
return "'" + value
return value
Format the output, do not just dump it
A raw to_excel dump is a table of unformatted numbers. Since the file is being generated anyway, spend a few lines on making it usable:
import io
import pandas as pd
def styled_workbook(df: pd.DataFrame, sheet: str = "Report") -> bytes:
buffer = io.BytesIO()
with pd.ExcelWriter(buffer, engine="xlsxwriter") as writer:
df.to_excel(writer, index=False, sheet_name=sheet)
book, ws = writer.book, writer.sheets[sheet]
money = book.add_format({"num_format": "#,##0.00"})
header = book.add_format({"bold": True, "bg_color": "#DDEBF7", "border": 1})
for col, name in enumerate(df.columns):
ws.write(0, col, name, header)
if pd.api.types.is_numeric_dtype(df[name]):
ws.set_column(col, col, 14, money)
ws.freeze_panes(1, 0)
ws.autofilter(0, 0, len(df), len(df.columns) - 1)
buffer.seek(0)
return buffer.getvalue()
That is the difference between an export people tolerate and one they rely on. The formatting vocabulary is covered across Formatting and Charting Excel Reports with Python.
Test the endpoint like an endpoint
An export is easy to test because the response body is a real workbook — read it back and assert on it:
import io
import pandas as pd
def test_export_returns_valid_workbook(client):
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.data), engine="openpyxl")
assert list(df.columns) == ["region", "revenue"]
assert len(df) == 2
Round-tripping the response through pandas catches the two failures that matter — a truncated buffer and a wrong sheet layout — without any knowledge of the internals. The wider testing approach is in Test Excel output with pytest.
Watch memory under concurrency
One export at a time is cheap; twenty concurrent exports of a large report are not. xlsxwriter builds the workbook in memory, so peak usage is roughly the size of the finished file plus the DataFrame it came from — multiplied by the number of simultaneous requests. Three mitigations, in order of preference: cap the rows an inline export may return, move large exports to the background queue, and use xlsxwriter's constant_memory mode for the genuinely large ones, accepting its restrictions. Sizing that trade-off is covered in Write a million rows to Excel with xlsxwriter constant memory.
Name the file so it makes sense on a desktop
A download called export.xlsx becomes export (3).xlsx within a week. Generate a name that carries the report, the scope and the period, and sanitise it so nothing from user data reaches the header:
"""Build a filename that is informative, unique and header-safe."""
import re
from datetime import date
SAFE = re.compile(r"[^A-Za-z0-9._-]+")
def export_filename(report: str, scope: str, when: date | None = None) -> str:
when = when or date.today()
stem = SAFE.sub("-", f"{report}-{scope}-{when:%Y-%m-%d}").strip("-")
return f"{stem[:80]}.xlsx"
print(export_filename("sales", "EMEA / North")) # sales-EMEA-North-2026-08-27.xlsx
Stripping everything outside a small character set matters for more than tidiness: a newline or a quote in a Content-Disposition header is a response-splitting bug, and a scope name coming from data is exactly where one would arrive. For non-ASCII names, add the RFC 5987 form alongside the plain one:
from urllib.parse import quote
name = "regional-résumé.xlsx"
disposition = f"attachment; filename=\"report.xlsx\"; filename*=UTF-8''{quote(name)}"
Offer CSV where Excel is not really needed
Not every export needs a workbook. If the recipient is loading the file into another system rather than reading it, CSV is faster to generate, streams incrementally, and has no memory ceiling:
| Excel export | CSV export | |
|---|---|---|
| Formatting, multiple sheets, formulas | Yes | No |
| True streaming, row by row | No — the zip finalises at close | Yes |
| Memory for a million rows | High | Flat |
| Opens cleanly in Excel | Yes | Mostly, with encoding caveats |
| Right for | A report a person reads | Data a system ingests |
Offering both is a few lines, and it moves the largest exports off the expensive path:
@app.get("/exports/sales.csv")
def export_sales_csv():
def rows():
yield "region,revenue\n"
for region, revenue in fetch_rows():
yield f"{region},{revenue}\n"
return Response(rows(), mimetype="text/csv",
headers={"Content-Disposition": 'attachment; filename="sales.csv"'})
Note the encoding caveat: Excel opens a UTF-8 CSV correctly only when it begins with a byte-order mark on some versions, so write utf-8-sig if the file is destined for a double-click rather than a parser. The conversion trade-offs are covered in Convert Excel to CSV with Python.
Cache a report that many people request
Reports are rarely per-user in content even when they are per-user in access. If ten managers export the same monthly summary within an hour, generating it ten times is wasted work — and each generation is the expensive part of the request:
"""Serve a cached workbook when one was built recently enough."""
import hashlib
import time
CACHE: dict[str, tuple[float, bytes]] = {}
TTL_SECONDS = 900
def cached_workbook(key_parts: tuple[str, ...], build) -> bytes:
key = hashlib.sha256("|".join(key_parts).encode()).hexdigest()
hit = CACHE.get(key)
if hit and time.time() - hit[0] < TTL_SECONDS:
return hit[1]
data = build()
CACHE[key] = (time.time(), data)
return data
Keep the cache key honest: it must include everything that changes the contents, including the permission scope. A cache keyed only on the report name will happily serve one team's numbers to another, which is the same bug as a missing authorisation check with a longer fuse. In a multi-process deployment, put the cache in Redis or object storage rather than a module-level dictionary, since each worker otherwise keeps its own copy.
Handle the failures a user will actually hit
Three things go wrong often enough to design for, and all three are worse when the response is a binary download — a browser that has already started saving cannot show an error page.
An empty result. A filter that matches nothing should still produce a workbook, with a header row and a note, rather than a zero-row file that looks broken:
if df.empty:
df = pd.DataFrame({"message": ["No rows matched the selected filters."]})
A generation error. Build the bytes fully before starting the response. If the build raises, you can still return a normal error page; if you have already begun streaming, you cannot:
try:
payload = build_workbook(df)
except Exception:
app.logger.exception("export failed for user %s", user.id)
abort(500)
return send_file(io.BytesIO(payload), ...)
A double-click. Users click export twice when nothing appears to happen. An idempotency key — or simply the cache above — turns the second click into a cheap repeat rather than a second full build. Disabling the button client-side while the request is in flight solves the visible half of the problem.
Logging is what makes these diagnosable after the fact: record the user, the filters, the row count and the elapsed time on every export. The pattern is the same one used for scheduled jobs in Log Python Excel script output to a file.
Deploy behind a proxy that knows what to expect
Two settings on the way out are easy to miss. Response buffering in nginx will hold a large download in memory or spool it to disk before forwarding it, which adds latency to every export; proxy_buffering off on the export location avoids that. And the proxy's read timeout must exceed the slowest inline export you allow, or users see a gateway error on exactly the reports that took the longest to produce — which is the strongest argument for the background-job route.
Compression is the other consideration: an .xlsx is already a zip, so gzip at the proxy adds CPU for essentially no saving. Exclude the spreadsheet content type from gzip_types and let the file through as it is.
Key takeaways
- Build workbooks in
io.BytesIO; a temporary file adds cleanup, a race between concurrent requests, and nothing else. - Close the writer before reading the buffer, and
seek(0)before returning it. - Two headers define the download: the spreadsheet content type and
Content-Disposition: attachment. - Keep generation synchronous only while it is fast; queue anything that takes longer than a few seconds.
- Scope every export query to the authenticated user, cap the row count, and escape leading formula characters.
- Test the endpoint by reading its response back with pandas — it catches truncation and layout errors in one assertion.
Frequently asked questions
Do I need to write the file to disk first?
No, and you should not. Build the workbook in an io.BytesIO buffer and return the bytes. Nothing touches the filesystem, so concurrent requests cannot collide over a filename and there is nothing to clean up.
What content type should an .xlsx download use?application/vnd.openxmlformats-officedocument.spreadsheetml.sheet. Pair it with a Content-Disposition header of attachment; filename="report.xlsx" so browsers save rather than try to display it.
How do I handle a report that takes two minutes to build? Do not build it in the request. Queue a background job, return a job id immediately, and let the client poll or receive a link when the file is ready. Anything past a few seconds risks a proxy timeout.
Can I stream an Excel file the way I can stream CSV?
Not incrementally in the same sense — an .xlsx is a zip, finalised only when the workbook closes. You can stream the finished bytes in chunks, which helps memory on large files, but the workbook must be complete first.
How do I stop one user downloading another user's report? Derive every filter from the authenticated session rather than from query parameters, and never build a file path from user input. Filenames in the response should be generated, not echoed back.
Conclusion
On-demand Excel is the same pipeline as a scheduled report, compressed into a request: query, build, deliver. Keep the build in memory, set the two headers that make it a download, format the output enough to be useful, and move anything slow to a queue before a proxy makes the decision for you. The guides below work each framework through in full, including the security and testing details that turn a working endpoint into one you can leave running.
Related
- Up: Automating Reporting Workflows — the scheduled counterpart to this on-demand delivery.
- Return an Excel file from a Flask download endpoint — the smallest complete implementation.
- Stream an Excel file from a FastAPI endpoint — async-safe generation and streaming responses.
- Build an Excel workbook in memory with BytesIO — the buffer pattern every framework shares.
- Export a Django queryset to Excel — queryset to workbook, including related fields and permissions.
- Handle an Uploaded Excel File in Flask and FastAPI — the other direction: receiving a spreadsheet safely and validating it.
- Sibling topics: Emailing Excel Reports with smtplib and Publishing Excel Reports to Cloud Storage — the other two ways a finished workbook reaches a person.