How-to
Automating Reporting WorkflowsDeep dive

Download an Excel File from a Dash App

Add an "Export to Excel" button to a Plotly Dash dashboard: dcc.Download with a callback that sends exactly the data the user filtered, a formatted multi-sheet workbook built in memory, a sensible file name, unit tests that call the callback and open the workbook it returns, and a Flask route on the Dash server for exports too large to send through a callback.

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

Bash
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.

What happens when the user clicks Export A sequence diagram. The browser sends the button click and the current filter value to the Dash server; the callback filters the data, builds the workbook in memory and returns its bytes with a file name; the dcc.Download component in the browser saves the file. browser Dash callback pandas / xlsxwriter 1. n_clicks + region filter 2. filter and build workbook 3. workbook bytes 4. send_bytes → file saved

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:

Python
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:

Python
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:

Python
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.

The downloaded workbook The Data sheet of the exported file with a header row and filter buttons, showing only North and East rows, and tabs for the Data and Summary sheets. sales_North-East_2026-09-29.xlsx A B C D 1 Date Region Product Sales 2 2026-07-01 00:00 North Lamps 3,412 3 2026-07-01 16:00 East Chairs 1,287 4 2026-07-02 08:00 North Tables 4,095 Data Summary Illustrative rows — the test above checks the real regions and totals.

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:

Python
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:

Python
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.

Callback download or Flask route Two panels. A dcc.Download callback is simplest and keeps everything in Dash, but sends base64 through the callback and suits files of a few megabytes. A Flask route on app.server streams bytes directly and suits large exports, at the cost of passing filters in the URL. dcc.Download callback reads filters with State no extra URL base64 in JSON, +33% size Flask route on app.server streams bytes directly fine for large files filters go in the query string

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=True on 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_format format 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.

More on excel in web apps

More on excel in web apps →