Download an Excel File from a Dash App
Dashboards built with Plotly Dash answer questions on screen, and the next thing users ask for is the data behind them — in Excel, filtered the way they have it on screen, to carry into their own analysis. Dash's dcc.Download component handles the browser side: a callback returns the file's content and name, and the browser saves it. The work is in making the export match what the user sees, building a workbook that is pleasant to open, and keeping large exports from choking the app. This page is part of Serving Excel Files from Python Web Apps.
Prerequisites
pip install dash pandas xlsxwriter openpyxl pyarrow
The dashboard shows 240 sales records from July to September with a region filter. The data is loaded once from sales.parquet when the app starts. The examples build the app and test its callbacks directly — the same way Dash's own documentation recommends unit-testing callbacks — so no browser is needed to verify the export.
Step 1 — The layout: a filter, a table and a Download component
dcc.Download is invisible; it waits for a callback to give it a file. Put it next to the button that triggers the export:
import pandas as pd
from dash import Dash, Input, Output, State, callback, dcc, html
sales = pd.read_parquet("sales.parquet")
app = Dash(__name__)
app.layout = html.Div([
dcc.Dropdown(sorted(sales["Region"].unique()), multi=True, id="region", placeholder="All regions"),
html.Div(id="rowcount"),
html.Button("Export to Excel", id="export"),
dcc.Download(id="download"),
])
def filtered(regions):
return sales if not regions else sales[sales["Region"].isin(regions)]
@callback(Output("rowcount", "children"), Input("region", "value"))
def update_view(regions):
return f"{len(filtered(regions)):,} rows"
print(update_view(None), "unfiltered;", update_view(["North"]), "for North")
Both the view and the export call the same filtered function, so the file the user downloads always contains exactly the rows they are looking at. Duplicating the filter logic in two callbacks is how an export ends up including rows the screen hid. The view here is a row count to keep the example short; a real dashboard shows the rows in a grid — Dash 4 deprecates the built-in DataTable in favour of dash-ag-grid — and feeds that grid from the same filtered function.
Step 2 — The export callback
The callback runs when the button is clicked, reads the current filter with State, builds the workbook and returns it through dcc.send_bytes, which takes a function that writes to a buffer:
from datetime import date
@callback(Output("download", "data"), Input("export", "n_clicks"), State("region", "value"),
prevent_initial_call=True)
def export_excel(n_clicks, regions):
data = filtered(regions)
summary = data.pivot_table(index="Region", columns="Product", values="Sales", aggfunc="sum", fill_value=0)
def write(buffer):
with pd.ExcelWriter(buffer, engine="xlsxwriter", datetime_format="yyyy-mm-dd hh:mm") as xw:
data.to_excel(xw, sheet_name="Data", index=False)
summary.to_excel(xw, sheet_name="Summary")
money = xw.book.add_format({"num_format": "#,##0"})
xw.sheets["Data"].set_column("A:A", 17)
xw.sheets["Data"].set_column("B:C", 10)
xw.sheets["Data"].set_column("D:D", 10, money)
xw.sheets["Data"].autofilter(0, 0, len(data), len(data.columns) - 1)
xw.sheets["Summary"].set_column("B:D", 12, money)
label = "-".join(regions) if regions else "all-regions"
return dcc.send_bytes(write, f"sales_{label}_{date.today():%Y-%m-%d}.xlsx",
type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
payload = export_excel(1, ["North", "East"])
print(payload["filename"], payload["base64"], len(payload["content"]), "base64 characters")
prevent_initial_call=True stops Dash running the callback when the page loads, which would otherwise trigger a download before anyone clicked. The workbook has two sheets — the filtered rows with an autofilter and number formats, and a region-by-product summary — written with xlsxwriter into the buffer Dash provides. The file name says what the file contains and when it was made, so a user with several downloads in their folder can tell them apart.
Step 3 — Test the callback and open what it returns
A decorated Dash callback is still an ordinary function. Call it with the inputs a user would produce, decode the content Dash would send, and open it as a workbook:
import base64
import io
def workbook_from(payload):
return pd.read_excel(io.BytesIO(base64.b64decode(payload["content"])), sheet_name=None)
sheets = workbook_from(export_excel(1, ["North", "East"]))
print(list(sheets))
print(sorted(sheets["Data"]["Region"].unique()), len(sheets["Data"]), "rows")
print(sheets["Summary"].set_index("Region").sum(axis=1).to_dict())
print(len(workbook_from(export_excel(1, None))["Data"]), "rows with no filter")
The test proves what matters: with North and East selected, the Data sheet contains only those regions, the Summary sheet's totals come from the same rows, and with no filter the export includes all 240 rows. Put these in the app's test suite with pytest; they run in milliseconds and need no browser. Test Excel Output with pytest covers checking formats and formulas as well.
Step 4 — Record what the file contains
A downloaded file lives on long after the session that produced it. Add a small sheet saying which filters were applied and when the export was made, so a colleague who receives the file weeks later knows what they are looking at:
from datetime import datetime
def about_sheet(regions, rows):
return pd.DataFrame({
"Setting": ["Exported at", "Regions", "Rows", "Source"],
"Value": [datetime.now().strftime("%Y-%m-%d %H:%M"), ", ".join(regions) if regions else "all",
rows, "sales dashboard"],
})
@callback(Output("download", "data", allow_duplicate=True), Input("export", "n_clicks"),
State("region", "value"), prevent_initial_call=True)
def export_with_about(n_clicks, regions):
data = filtered(regions)
def write(buffer):
with pd.ExcelWriter(buffer, engine="xlsxwriter") as xw:
data.to_excel(xw, sheet_name="Data", index=False)
about_sheet(regions, len(data)).to_excel(xw, sheet_name="About", index=False)
return dcc.send_bytes(write, "sales_export.xlsx")
about = workbook_from(export_with_about(1, ["South"]))["About"]
print(about.set_index("Setting").loc[["Regions", "Rows"], "Value"].to_dict())
allow_duplicate=True lets this second callback target the same dcc.Download output as the first — in a real app you would keep one export callback and add the sheet to it. The About sheet costs nothing and saves the "where did this file come from?" conversation.
Step 5 — Large exports: a download route
dcc.Download sends the file through Dash's callback machinery as base64 text inside JSON, which adds a third to its size and holds the whole thing in memory on the server and in the browser. That is fine for a few megabytes. For large exports, serve the file from an ordinary Flask route on the Dash server — Dash runs on Flask, available as app.server — and link to it:
from flask import request, send_file
@app.server.route("/export/sales.xlsx")
def export_route():
regions = request.args.getlist("region")
buffer = io.BytesIO()
filtered(regions).to_excel(buffer, index=False)
buffer.seek(0)
return send_file(buffer, as_attachment=True, download_name="sales_export.xlsx",
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
client = app.server.test_client()
resp = client.get("/export/sales.xlsx?region=West")
print(resp.status_code, resp.headers["Content-Disposition"])
print(pd.read_excel(io.BytesIO(resp.data))["Region"].unique().tolist())
The route streams the bytes directly with the right headers, and a plain link or an html.A in the layout — pointing at the URL with the current filter in the query string — triggers the download. For exports that take more than a few seconds to build, generate them in a background job and link to the finished file instead; see Generate Large Excel Exports in a Background Job.
Performance notes
Building the workbook is the slow part of an export, and it runs inside a request: a few thousand rows take a fraction of a second, but a few hundred thousand take long enough that the browser seems to hang and a proxy may time out. xlsxwriter is the faster writer for new files, and constant_memory mode keeps its memory flat for very long sheets. Loading the data once at start-up, as here, rather than on every click, keeps exports quick; if the data changes during the day, cache it with a short expiry instead of re-reading it per request.
Common pitfalls
- Exporting different rows than the screen shows. Filter through one shared function for the table and the export.
- Downloads on page load. Set
prevent_initial_call=Trueon the export callback. - Big files through the callback. base64 in JSON bloats them; use a Flask route above a few megabytes.
- Dates as text. Write real datetimes and let
datetime_formatformat them, so users can filter and sort by date in Excel. - Anyone can call the route. A Flask route bypasses nothing by itself, but if the dashboard has access control, apply the same checks to the export route.
Conclusion
Add a dcc.Download component and an export callback triggered by a button with prevent_initial_call=True, read the current filters with State, and build the workbook in memory with dcc.send_bytes and a writer function — formatted sheets, a summary, a descriptive file name. Share one filtering function between the display and the export, test the callback by calling it and opening the decoded workbook, and move large exports to a Flask route on app.server.
Frequently asked questions
How do I download an Excel file from a Dash app? Add dcc.Download(id="download") to the layout and a callback with Output("download", "data") that returns dcc.send_bytes(writer, "file.xlsx") or dcc.send_data_frame(df.to_excel, "file.xlsx", index=False).
How do I export only the filtered data? Read the filter components' values with State in the export callback and apply the same filtering function the table uses, so the file matches what is on screen.
How do I write several sheets to the downloaded file? Use dcc.send_bytes with a function that receives a buffer and writes to it with pd.ExcelWriter, calling to_excel once per sheet and adding formats through the writer's workbook.
How do I test a Dash download callback? Call the callback function directly with the input values, base64-decode the content field of the returned dictionary, and read the bytes with pandas to check the sheets and rows.
Related
- Up one level: Serving Excel Files from Python Web Apps — Excel in and out of web applications.
- Build a Streamlit App to Upload and Download Excel — the Streamlit equivalent.
- Return an Excel File from a Flask Download Endpoint — the Flask route in detail.
- Build an Excel Workbook in Memory with BytesIO — the in-memory writing both routes use.
More on excel in web apps
- How-toSchedule Excel Reports with Celery Beat
- How-toTrigger an Excel Report from a Webhook
- How-toAdd an Export to Excel Action in Django Admin
- How-toBuild a Gradio App That Cleans an Uploaded Excel File
- How-toBuild a Streamlit App to Upload and Download Excel Files
- How-toBuild an Excel Workbook in Memory with BytesIO