29 min read

> "Streamlit asks you to think in scripts. Dash asks you to think in dependencies. Both are right for different problems."

Learning Objectives

  • Explain Dash's architecture: layout (HTML/DCC components) + callbacks (reactive function declarations)
  • Build a Dash layout using html and dcc components: Div, H1, Graph, Dropdown, Slider, DatePickerRange, Tabs
  • Write callbacks using @app.callback(Output, Input, State) for reactive interactivity
  • Implement cross-filtering: clicking or selecting in one chart updates another chart
  • Create multi-tab and multi-page Dash applications
  • Style Dash apps with CSS, external stylesheets, and Dash Bootstrap Components
  • Compare Dash and Streamlit and choose the right tool for each use case

Chapter 30: Building Dashboards with Dash

"Streamlit asks you to think in scripts. Dash asks you to think in dependencies. Both are right for different problems." — anonymous engineering blog, adapted


30.1 A Different Execution Model

Chapter 29 introduced Streamlit, whose execution model is "re-run the script top to bottom on every interaction." Chapter 30 introduces Dash, which takes the opposite approach. Instead of re-running everything, Dash uses explicit callbacks: you declare which function produces which output based on which input, and the framework calls your function only when the relevant inputs change.

This is a harder mental model, but it has advantages. Callbacks are precise — only the code that needs to run actually runs. Complex dependency graphs are explicit in the code rather than implicit in the script order. Cross-filtering between multiple charts, where clicking in chart A filters chart B, is natural in Dash and awkward in Streamlit. For production dashboards with sophisticated interactivity, Dash's explicit model usually scales better.

Dash was released by Plotly in 2017 as an open-source Python framework for building web applications. It is built on Flask (the Python web framework) and Plotly.js (the JavaScript chart library). A Dash application is a Python script, but the structure is different from a Streamlit script: you define a layout (a tree of HTML and Dash Core Components) and then attach callbacks (functions decorated with @app.callback) that update parts of the layout based on user interaction.

The chapter's threshold concept is that callbacks are reactive declarations, not imperative event handlers. You do not write "when the slider moves, do X." You write "output Y depends on input X," and Dash calls your function whenever X changes. The difference is subtle but important: you are declaring relationships, not describing actions.

This chapter covers Dash's architecture, the layout and callback system, cross-filtering, multi-page apps, styling with Dash Bootstrap Components, and a decision framework for choosing between Dash and Streamlit.

30.2 Architecture: Layout Plus Callbacks

A Dash application has two main parts: a layout and a set of callbacks.

The layout is a tree of components — HTML elements (html.Div, html.H1, html.P) and Dash Core Components (dcc.Graph, dcc.Dropdown, dcc.Slider). You construct the layout as a Python object and assign it to app.layout. Dash renders the layout as HTML in the browser.

Callbacks are Python functions decorated with @app.callback that take inputs from components and return outputs to other components. When the user interacts with an input component (moves a slider, selects a dropdown value), Dash calls the decorated function with the new input values and updates the output component with the function's return value.

The minimal Dash app:

from dash import Dash, html, dcc, Input, Output
import plotly.express as px
import pandas as pd

df = px.data.gapminder()

app = Dash(__name__)

app.layout = html.Div([
    html.H1("Gapminder Explorer"),
    dcc.Dropdown(
        id="year-dropdown",
        options=[{"label": str(y), "value": y} for y in df["year"].unique()],
        value=2007,
    ),
    dcc.Graph(id="scatter-graph"),
])

@app.callback(
    Output("scatter-graph", "figure"),
    Input("year-dropdown", "value"),
)
def update_chart(selected_year):
    filtered = df[df["year"] == selected_year]
    fig = px.scatter(filtered, x="gdpPercap", y="lifeExp",
                     size="pop", color="continent", log_x=True)
    return fig

if __name__ == "__main__":
    app.run(debug=True)

Running this script starts a Dash server (by default at http://127.0.0.1:8050). A browser tab shows the layout: a heading, a dropdown, and an empty graph slot. When the user selects a year from the dropdown, Dash calls update_chart with the new value, receives the returned Plotly figure, and updates the scatter-graph component. No page reload; only the graph updates.

The key pieces:

  • Components have IDs. dcc.Dropdown(id="year-dropdown", ...). The ID is how callbacks reference the component.
  • Components have properties. dcc.Graph has a figure property (the Plotly figure object), dcc.Dropdown has a value property (the currently-selected value), html.Div has a children property (its contents).
  • Callbacks connect properties. @app.callback(Output("scatter-graph", "figure"), Input("year-dropdown", "value")) says "the figure property of scatter-graph depends on the value property of year-dropdown."
  • The decorated function is the implementation. When Dash needs to compute the output, it calls the function with the current input values.

This is fundamentally different from Streamlit. In Streamlit, the script re-runs and st.plotly_chart is called with a new figure. In Dash, the framework calls a specific callback with specific inputs, and only that callback runs. The rest of the layout does not re-execute.

30.3 HTML Components and Dash Core Components

Dash provides two main component libraries:

dash.html: HTML elements wrapped as Python classes. html.Div, html.H1, html.H2, html.P, html.Span, html.A (anchor), html.Img, html.Button, html.Label, html.Br, html.Hr, html.Table, html.Form, and many more. These map directly to HTML tags and accept the same CSS styling properties.

dash.dcc (Dash Core Components): interactive widgets. dcc.Graph, dcc.Dropdown, dcc.Slider, dcc.RangeSlider, dcc.Input, dcc.Textarea, dcc.Checklist, dcc.RadioItems, dcc.DatePickerSingle, dcc.DatePickerRange, dcc.Upload, dcc.Tabs, dcc.Tab, dcc.Loading, dcc.Interval, and more. These are the "widget" equivalents of Streamlit's widgets, but as layout components rather than function calls.

A layout is built by nesting these components:

app.layout = html.Div([
    html.H1("Dashboard Title"),
    html.Div([
        html.Div([
            html.Label("Year"),
            dcc.Dropdown(id="year", options=[...]),
        ], style={"width": "30%", "display": "inline-block"}),
        html.Div([
            html.Label("Region"),
            dcc.Dropdown(id="region", options=[...]),
        ], style={"width": "30%", "display": "inline-block"}),
    ]),
    dcc.Graph(id="main-graph"),
], style={"padding": "20px"})

The nested structure mirrors HTML: a top-level Div containing an H1, another Div with two child Divs for the filters, and a Graph. Inline styling via the style argument uses Python dicts with CSS property names (camelCase: backgroundColor instead of background-color).

Most components accept a style argument for CSS, a className for CSS class names (useful with external stylesheets), and an id for callback references. The full list of properties depends on the component — dcc.Graph has figure, config, style, className, clickData, selectedData, hoverData, and more.

30.4 Writing Callbacks

The @app.callback decorator is the heart of Dash. Its signature:

@app.callback(
    Output("component-id", "property"),
    Input("other-component-id", "property"),
    State("third-component-id", "property"),  # optional
)
def my_callback(input_value, state_value):
    # compute output from inputs
    return output_value

The decorator takes Output, Input, and State objects specifying which component properties the callback reads and writes.

  • Output: the component property this callback updates. Can be a single Output or a list of multiple outputs.
  • Input: the component property whose change triggers this callback. Can be a single Input or a list. When any Input changes, the callback fires.
  • State: the component property whose value is read but does not trigger the callback. Useful for forms where you want to read a value only when a button is clicked, not on every change.

The function's arguments correspond to the Inputs and States in order. The function's return value corresponds to the Outputs in order.

Example: a two-input callback.

@app.callback(
    Output("chart", "figure"),
    Input("year-dropdown", "value"),
    Input("region-dropdown", "value"),
)
def update_chart(year, region):
    filtered = df[(df["year"] == year) & (df["region"] == region)]
    return px.scatter(filtered, x="x", y="y")

When either dropdown changes, Dash calls the function with both current values and updates the chart.

Example: a State dependency.

@app.callback(
    Output("output", "children"),
    Input("submit-button", "n_clicks"),
    State("text-input", "value"),
)
def handle_submit(n_clicks, text_value):
    if n_clicks is None:
        return ""
    return f"You entered: {text_value}"

The callback only fires when the button's n_clicks changes (i.e., when the button is clicked). The text-input value is read at that moment but changing it doesn't trigger the callback. This pattern is typical for forms.

Example: multiple outputs.

@app.callback(
    [Output("chart1", "figure"), Output("chart2", "figure")],
    Input("filter", "value"),
)
def update_both_charts(filter_value):
    filtered = df[df["category"] == filter_value]
    fig1 = px.line(filtered, x="date", y="value")
    fig2 = px.histogram(filtered, x="value")
    return fig1, fig2

The function returns a tuple; Dash assigns each element to the corresponding Output.

30.5 Cross-Filtering Between Charts

Dash's killer feature is cross-filtering: clicking or selecting in one chart updates another chart. Plotly charts emit events when the user interacts with them (clicking a point, brushing a region, hovering), and Dash can use these events as callback inputs.

The relevant properties on dcc.Graph:

  • clickData: the data from the most recently clicked point.
  • selectedData: the data from the most recent brush selection (lasso or box).
  • hoverData: the data under the cursor.
  • relayoutData: the current zoom/pan state.

These properties change as the user interacts, and you can use them as callback inputs.

Example: clicking a bar filters a scatter plot.

@app.callback(
    Output("scatter", "figure"),
    Input("bar", "clickData"),
)
def filter_scatter(click_data):
    if click_data is None:
        return px.scatter(df, x="x", y="y")
    category = click_data["points"][0]["x"]
    filtered = df[df["category"] == category]
    return px.scatter(filtered, x="x", y="y",
                      title=f"Filtered to {category}")

When the user clicks a bar in the bar chart, the clickData property updates with information about the clicked point. The callback reads the x-value of the clicked bar and filters the scatter plot to show only that category. The scatter plot's title also updates to reflect the current filter.

Example: brushing a scatter plot to filter a map.

@app.callback(
    Output("map", "figure"),
    Input("scatter", "selectedData"),
)
def filter_map(selected_data):
    if selected_data is None:
        return px.scatter_geo(df, lat="lat", lon="lon")
    selected_ids = [p["customdata"][0] for p in selected_data["points"]]
    filtered = df[df["id"].isin(selected_ids)]
    return px.scatter_geo(filtered, lat="lat", lon="lon")

The user drags a box on the scatter plot; the selectedData property contains all the points inside the box; the callback filters the map to show only those points.

Cross-filtering is what makes Dash feel like a "real" interactive dashboard rather than a set of disconnected charts. It implements Shneiderman's brushing-and-linking pattern directly, and it is one of the main reasons to choose Dash over Streamlit for complex exploratory dashboards.

30.6 Callback Chains and Dependencies

Callbacks can depend on other callbacks' outputs. If callback A updates component X, and callback B takes component X as an input, B fires after A. Dash automatically tracks these dependencies and fires callbacks in the correct order.

Example: a filter updates a dropdown, which updates a chart.

@app.callback(
    Output("city-dropdown", "options"),
    Input("country-dropdown", "value"),
)
def update_cities(country):
    cities = df[df["country"] == country]["city"].unique()
    return [{"label": c, "value": c} for c in cities]

@app.callback(
    Output("chart", "figure"),
    Input("city-dropdown", "value"),
)
def update_chart(city):
    return px.line(df[df["city"] == city], x="date", y="value")

When the user picks a country, the first callback fires and updates the city dropdown's options. When the city dropdown updates, the second callback fires and updates the chart. The chain is automatic — Dash figures out the order from the declared dependencies.

Circular dependencies are an error. If callback A writes to component X and reads component Y, and callback B writes to component Y and reads component X, Dash detects the cycle and refuses to run. Breaking a cycle usually requires rethinking the callback structure or using dash.no_update (a special return value that skips updating a specific output).

30.7 Styling with Dash Bootstrap Components

Dash layouts can be styled with inline CSS (style=), external stylesheets, or component libraries. The most popular component library is Dash Bootstrap Components (dbc), which wraps the Bootstrap CSS framework for Dash.

Installation: pip install dash-bootstrap-components

Usage:

import dash_bootstrap_components as dbc

app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])

app.layout = dbc.Container([
    dbc.Row(dbc.Col(html.H1("My Dashboard"), width=12)),
    dbc.Row([
        dbc.Col([
            dbc.Label("Year"),
            dcc.Dropdown(id="year", options=[...]),
        ], width=4),
        dbc.Col([
            dbc.Label("Region"),
            dcc.Dropdown(id="region", options=[...]),
        ], width=4),
    ]),
    dbc.Row(dbc.Col(dcc.Graph(id="chart"), width=12)),
])

Bootstrap Components provide:

  • Layout: Container, Row, Col for responsive grid layouts.
  • Cards: Card, CardBody, CardHeader, CardFooter for bordered content sections.
  • Forms: Form, FormGroup, Label, Input for styled form elements.
  • Buttons: Button with color variants (color="primary", color="danger", etc.).
  • Navigation: Nav, NavItem, NavLink for navigation bars.
  • Alerts: Alert for notifications and warnings.
  • Badges and tooltips: Badge, Tooltip for contextual information.

The result is dashboards that look polished without writing CSS. Themes are available: dbc.themes.BOOTSTRAP, dbc.themes.DARKLY, dbc.themes.FLATLY, dbc.themes.CERULEAN, and many more. Switching themes is a one-line change.

For custom CSS beyond Bootstrap, create an assets/ folder next to your app file. Any CSS files in that folder are automatically loaded by Dash. Any images go in assets/ too and can be referenced with /assets/filename.

30.8 Multi-Page Dash Applications

For apps with multiple distinct views, Dash supports multi-page applications. Dash 2.0+ introduced dash.register_page for cleanly defining pages.

# app.py
from dash import Dash, html, dcc, page_container, page_registry

app = Dash(__name__, use_pages=True, pages_folder="pages")

app.layout = html.Div([
    html.H1("Multi-Page App"),
    html.Div([
        dcc.Link(page["name"], href=page["relative_path"])
        for page in page_registry.values()
    ], style={"display": "flex", "gap": "1em"}),
    page_container,
])

if __name__ == "__main__":
    app.run(debug=True)
# pages/home.py
from dash import html, register_page

register_page(__name__, path="/", name="Home")

layout = html.Div([
    html.H2("Home Page"),
    html.P("Welcome to the dashboard."),
])
# pages/analysis.py
from dash import html, register_page

register_page(__name__, path="/analysis", name="Analysis")

layout = html.Div([
    html.H2("Analysis"),
    # ... components and callbacks ...
])

Each page is a Python module in the pages/ folder that calls register_page with a URL path and a name. The main app uses page_container to render the currently-selected page and page_registry to generate navigation links. Navigation between pages does not cause a full reload — Dash uses client-side routing for a smooth experience.

Multi-page apps are useful when the dashboard has several distinct workflows. For simpler apps, a single-page layout with dcc.Tabs is usually enough.

30.9 The dcc.Interval Component for Auto-Refresh

For dashboards that need to update automatically at regular intervals (live monitoring, real-time metrics), Dash provides dcc.Interval:

app.layout = html.Div([
    dcc.Interval(id="interval", interval=5000, n_intervals=0),
    dcc.Graph(id="live-chart"),
])

@app.callback(
    Output("live-chart", "figure"),
    Input("interval", "n_intervals"),
)
def update_chart(n):
    df = fetch_latest_data()
    return px.line(df, x="time", y="value")

The dcc.Interval component fires its n_intervals callback every interval milliseconds (here, every 5 seconds). The callback re-fetches data and updates the chart. The result is a dashboard that refreshes automatically without user interaction.

This is one area where Dash is clearly superior to Streamlit. Streamlit can approximate auto-refresh with tricks (time-based cache expiration, st.rerun with delays), but none of the approaches are as clean as Dash's dcc.Interval. For real-time dashboards, Dash is usually the right choice.

30.10 Dash vs. Streamlit: When to Choose Each

Both Dash and Streamlit are capable Python dashboard frameworks. The choice depends on the project's requirements.

Dimension Streamlit Dash
Execution model Script re-runs top-to-bottom Explicit callbacks
Learning curve Gentle Steeper
Lines of code for simple dashboard Fewer More
Lines of code for complex dashboard About the same About the same
Cross-filtering Awkward Native and powerful
Auto-refresh Workarounds dcc.Interval, clean
Custom CSS Limited Full CSS support
Enterprise features Streamlit Cloud free tier, Snowflake integration Dash Enterprise (paid), Plotly product suite
Community size Large, fast-growing Large, mature
Best for Quick prototypes, ML demos, internal tools Production dashboards, complex interactivity, enterprise deployments

Choose Streamlit when:

  • You need a dashboard by the end of the day.
  • The dashboard is simple (sidebar filters + a few charts).
  • You are prototyping or demoing an ML model.
  • You want to deploy for free on Streamlit Cloud.
  • Your team is new to dashboard frameworks.

Choose Dash when:

  • You need cross-filtering between multiple charts.
  • The dashboard has complex interactive logic with many interdependencies.
  • You need auto-refresh for live data.
  • You need full CSS customization or brand-specific styling.
  • The app will have many concurrent users and needs enterprise scaling.
  • You are already invested in the Plotly ecosystem.

For many projects, both would work. Try Streamlit first (lower initial investment), and switch to Dash if you hit specific limitations. The rewrite is usually a day or two for a moderate-sized dashboard — not trivial, but not disastrous.

30.11 Dash Pitfalls

Circular callback dependencies. Two callbacks referencing each other's outputs produce a cycle Dash refuses to run. Fix: restructure so dependencies flow in one direction, or use dash.no_update to avoid triggering one side.

Slow callbacks blocking the UI. A callback that takes 30 seconds freezes the dashboard. Fix: use dcc.Loading to show a spinner, move the slow computation to a background thread (Dash 2.6+ supports long_callback), or pre-compute and cache expensive results.

Stale clickData after re-renders. If a chart re-renders, its clickData may not reset. Callbacks that depend on clickData may fire unnecessarily. Fix: check whether the click data is actually new before reacting.

Too many callbacks. A dashboard with 50 callbacks becomes hard to reason about. Fix: group related logic into a single callback with multiple outputs, or refactor into a multi-page structure.

Inline styles scattered throughout the layout. Hard to maintain. Fix: use external CSS or Dash Bootstrap Components for a consistent styling approach.

Running from Jupyter. Dash can run from Jupyter via JupyterDash or app.run(mode="inline"), but the experience is not as smooth as from a script. For production dashboards, run from the command line.

No authentication by default. Dash apps are open to anyone who can reach the server. For internal dashboards, add authentication with dash-auth, a reverse proxy, or a framework like Flask-Login. For public apps without sensitive data, no auth is fine.

30.12 Progressive Project: Corporate Sales Dashboard

The chapter's climate project has been the main throughline, but Chapter 30 uses the corporate sales dataset (Meridian Corp) as its example because cross-filtering is more natural for sales data than climate data.

The dashboard has:

  • KPI cards at the top: total revenue, units sold, average order value, top product.
  • Filter row: date range picker, region dropdown, product line multi-select.
  • Main chart row: revenue over time (line chart), revenue by region (bar chart), revenue by product (treemap).
  • Cross-filtering: clicking a region in the bar chart filters the line and treemap. Brushing a date range in the line chart filters the bar and treemap.

The structure in Dash:

from dash import Dash, html, dcc, Input, Output, callback
import dash_bootstrap_components as dbc
import plotly.express as px
import pandas as pd

app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])

df = pd.read_csv("meridian_sales.csv", parse_dates=["date"])

app.layout = dbc.Container([
    html.H1("Meridian Corp Sales Dashboard"),
    dbc.Row([
        dbc.Col(dbc.Card([dbc.CardBody([html.H6("Revenue"), html.H2(id="kpi-revenue")])]), width=3),
        dbc.Col(dbc.Card([dbc.CardBody([html.H6("Units"), html.H2(id="kpi-units")])]), width=3),
        dbc.Col(dbc.Card([dbc.CardBody([html.H6("Avg order"), html.H2(id="kpi-avg")])]), width=3),
        dbc.Col(dbc.Card([dbc.CardBody([html.H6("Top product"), html.H2(id="kpi-top")])]), width=3),
    ], className="mb-3"),
    dbc.Row([
        dbc.Col(dcc.DatePickerRange(id="date-range",
                                     min_date_allowed=df["date"].min(),
                                     max_date_allowed=df["date"].max(),
                                     start_date=df["date"].min(),
                                     end_date=df["date"].max()), width=4),
        dbc.Col(dcc.Dropdown(id="region-dropdown",
                             options=[{"label": r, "value": r} for r in df["region"].unique()]), width=4),
        dbc.Col(dcc.Dropdown(id="product-multiselect",
                             options=[{"label": p, "value": p} for p in df["product"].unique()],
                             multi=True), width=4),
    ], className="mb-3"),
    dbc.Row([
        dbc.Col(dcc.Graph(id="time-chart"), width=6),
        dbc.Col(dcc.Graph(id="region-chart"), width=6),
    ]),
    dbc.Row([
        dbc.Col(dcc.Graph(id="treemap"), width=12),
    ]),
], fluid=True)

@callback(
    [Output("time-chart", "figure"),
     Output("region-chart", "figure"),
     Output("treemap", "figure"),
     Output("kpi-revenue", "children"),
     Output("kpi-units", "children"),
     Output("kpi-avg", "children"),
     Output("kpi-top", "children")],
    [Input("date-range", "start_date"),
     Input("date-range", "end_date"),
     Input("region-dropdown", "value"),
     Input("product-multiselect", "value")],
)
def update_dashboard(start, end, region, products):
    mask = (df["date"] >= start) & (df["date"] <= end)
    if region:
        mask &= df["region"] == region
    if products:
        mask &= df["product"].isin(products)
    filtered = df[mask]

    time_fig = px.line(filtered.groupby("date")["revenue"].sum().reset_index(),
                       x="date", y="revenue", title="Revenue Over Time")
    region_fig = px.bar(filtered.groupby("region")["revenue"].sum().reset_index(),
                        x="region", y="revenue", title="Revenue by Region")
    treemap = px.treemap(filtered, path=["region", "product"], values="revenue",
                         title="Product Breakdown")

    return (
        time_fig, region_fig, treemap,
        f"${filtered['revenue'].sum():,.0f}",
        f"{filtered['units'].sum():,}",
        f"${filtered['revenue'].sum() / max(filtered['order_id'].nunique(), 1):,.0f}",
        filtered.groupby("product")["revenue"].sum().idxmax() if len(filtered) else "—",
    )

if __name__ == "__main__":
    app.run(debug=True)

This is about 75 lines — comparable to the Streamlit version from Chapter 29 for similar functionality. The Dash version has the advantage of explicit callback structure and the ability to easily add cross-filtering later. Clicking a region bar could filter the line chart, for example, with a small extension to the callback.

30.13 Clientside Callbacks for Performance

Most Dash callbacks run on the Python server. For simple UI logic that does not need Python computation (e.g., toggling visibility, updating a counter, reformatting text), running on the server introduces network latency and server load. Clientside callbacks let you run callbacks in the browser using JavaScript, eliminating the round-trip.

app.clientside_callback(
    """
    function(value) {
        return "You entered: " + value;
    }
    """,
    Output("output-text", "children"),
    Input("input-text", "value"),
)

The callback logic is a JavaScript function as a string. Dash sends it to the browser, and the browser executes it directly when the input changes. No Python roundtrip, no server load.

Clientside callbacks are appropriate for:

  • Simple UI transformations: toggling classes, changing text, updating counters.
  • Performance-critical interactions: events that fire frequently (mouse move, drag) where server latency would be perceptible.
  • Calculations that do not need Python: if the computation is simple arithmetic or string manipulation, JavaScript can do it in milliseconds.

They are not appropriate for:

  • Data loading: the browser cannot read files from the server.
  • Complex computation: JavaScript is fine for simple work but cannot easily do numpy-style operations.
  • Anything that needs Python libraries: ML predictions, pandas transformations, database queries.

For most dashboards, serverside callbacks are the default. Clientside callbacks are an optimization you reach for when specific interactions are slow and the logic is simple enough to port to JavaScript.

30.14 Long-Running Callbacks

Some callbacks take a long time to run — training a model, fetching from a slow API, processing a large file. During a long callback, the Dash UI is frozen: the user cannot interact with other components until the callback finishes. This is bad for user experience.

Dash 2.6+ introduces long callbacks for this case. A long callback runs in a background process, leaves the UI responsive during execution, and updates the output when done.

from dash import DiskcacheManager, long_callback
import diskcache

cache = diskcache.Cache("./cache")
background_callback_manager = DiskcacheManager(cache)

app = Dash(__name__, background_callback_manager=background_callback_manager)

@app.long_callback(
    Output("output", "children"),
    Input("start", "n_clicks"),
    running=[
        (Output("start", "disabled"), True, False),
        (Output("output", "children"), "Processing...", ""),
    ],
    progress=[Output("progress", "value")],
)
def long_task(set_progress, n_clicks):
    for i in range(100):
        time.sleep(0.1)
        set_progress(str(i + 1))
    return "Done!"

The running argument specifies temporary values during execution (here, disabling the start button and showing "Processing..."). The progress argument lets the callback report progress updates. The set_progress function is passed to the callback implicitly and can be called during execution to update progress components.

Long callbacks require a background callback manager — a backend that runs the callback out-of-process. Dash provides DiskcacheManager for simple cases and CeleryManager for production deployments with distributed task queues.

For most dashboards, long callbacks are unnecessary — regular callbacks run in a few hundred milliseconds, and the user does not notice. But for ML inference, data downloads, and other slow operations, long callbacks turn an unusable UI into a responsive one.

30.15 Global vs. Callback-Scoped Data

A subtle but important question in Dash apps: where does the data live?

Global loading: load the DataFrame once at the top of the script, outside any callback. Every callback can reference it.

df = pd.read_csv("data.csv")

@app.callback(Output("chart", "figure"), Input("filter", "value"))
def update_chart(value):
    filtered = df[df["col"] == value]
    return px.scatter(filtered)

This is the simplest pattern and works for datasets that fit comfortably in memory. The data is loaded once when the app starts and shared across all callbacks and all users.

Callback-scoped loading: load the DataFrame inside the callback.

@app.callback(Output("chart", "figure"), Input("filter", "value"))
def update_chart(value):
    df = pd.read_csv("data.csv")  # bad: reloads on every call
    filtered = df[df["col"] == value]
    return px.scatter(filtered)

This is almost always wrong — it reloads the data on every callback invocation, which is wasteful. Use global loading instead.

Per-user state with dcc.Store: for data that should be specific to each user session (e.g., a user's upload, their filters, their preferences), use dcc.Store components to hold state in the browser.

app.layout = html.Div([
    dcc.Store(id="user-data"),
    dcc.Upload(id="upload"),
    dcc.Graph(id="chart"),
])

@app.callback(
    Output("user-data", "data"),
    Input("upload", "contents"),
)
def store_upload(contents):
    df = parse_upload(contents)
    return df.to_dict("records")

@app.callback(
    Output("chart", "figure"),
    Input("user-data", "data"),
)
def render_chart(data):
    if data is None:
        return {}
    df = pd.DataFrame(data)
    return px.scatter(df, x="x", y="y")

The dcc.Store component holds the data in the browser's memory (or local storage). Each user has their own data, and callbacks read from the Store to build their output. This pattern is how Dash handles per-user state cleanly.

30.16 Dash Enterprise and Commercial Features

Dash is open source and free to use. Plotly (the company) sells a commercial product called Dash Enterprise that adds features for enterprise deployments:

  • Deployment platform: managed hosting with auto-scaling, load balancing, and zero-downtime updates.
  • User authentication: SSO, OAuth, LDAP, and role-based access control.
  • Snapshot scheduling: automated generation of PDF reports from Dash apps.
  • App Manager: a web UI for managing deployed apps across an organization.
  • Kubernetes integration: for companies running their own Kubernetes clusters.
  • Support and SLAs: traditional enterprise support contracts.

Dash Enterprise is aimed at large organizations that need production infrastructure around their dashboards. For individuals, small teams, and hobbyists, the open-source Dash library is sufficient. The separation between open-source library and paid platform is similar to other developer tools (GitHub vs. GitHub Enterprise, for example).

Plotly also sells Chart Studio (a hosted chart editor) and has a consulting arm that builds custom Dash apps for clients. These are the commercial revenue streams that fund the open-source Dash development.

For personal projects, the free tier is more than enough. For company-wide internal tools, you can self-host Dash on AWS/GCP/Azure without needing Dash Enterprise. For enterprise deployments with specific requirements (SSO, scaling, SLAs), Dash Enterprise may be worth the cost — but evaluate carefully because the pricing is not trivial.

30.17 Testing Dash Apps

Dash apps can be tested more thoroughly than Streamlit apps because the layout and callbacks are separable.

Unit testing callbacks: callbacks are just Python functions. You can test them directly:

def test_update_chart():
    fig = update_chart(2007)
    assert isinstance(fig, go.Figure)
    assert fig.layout.title.text == "2007 Data"

Layout testing: walk the layout tree and assert on specific components:

def test_layout_has_dropdown():
    dropdown = find_by_id(app.layout, "year-dropdown")
    assert dropdown is not None
    assert len(dropdown.options) > 0

Integration testing with dash.testing: a pytest-based framework for running a Dash app in a real browser (via Selenium) and asserting on the rendered output.

def test_dropdown_updates_chart(dash_duo):
    dash_duo.start_server(app)
    dash_duo.wait_for_element("#year-dropdown")
    dash_duo.select_dcc_dropdown("#year-dropdown", "2007")
    dash_duo.wait_for_text_to_equal("#chart-title", "2007 Data")

The dash_duo fixture manages a browser instance and provides helper methods for interacting with Dash components. Tests run slowly (seconds per test) but catch real-world issues that unit tests miss.

For most projects, focus unit tests on the callback logic and use a handful of integration tests for the critical user journeys. Full end-to-end coverage is usually not worth the maintenance cost.

30.18 Pattern-Matching Callbacks

For dashboards with dynamically generated components (e.g., a list of cards where each card has its own interactive elements), writing one callback per component becomes tedious. Dash's pattern-matching callbacks let you write one callback that handles all matching components.

The pattern-matching syntax uses {"type": "...", "index": ...} as component IDs:

from dash import MATCH, ALL

# Layout: three dynamically generated sliders
app.layout = html.Div([
    html.Div([
        dcc.Slider(id={"type": "dynamic-slider", "index": i},
                   min=0, max=100, value=50)
        for i in range(3)
    ]),
    html.Div(id="output"),
])

@app.callback(
    Output("output", "children"),
    Input({"type": "dynamic-slider", "index": ALL}, "value"),
)
def update_output(values):
    return f"Sum of all sliders: {sum(values)}"

The {"type": "dynamic-slider", "index": ALL} pattern matches all components with that type and any index. Dash collects all their values into a list and passes it to the callback. The callback then works on the list without knowing in advance how many components there are.

Similar patterns:

  • ALL: match all values of this key.
  • MATCH: match the same value as the Output's key (for per-component updates).
  • ALLSMALLER: match values smaller than the Output's key.

Pattern-matching callbacks are essential for dynamic UIs — dashboards where the number of controls depends on the data or the user's actions. They make Dash as expressive as component-framework-based alternatives like React, while keeping the Python syntax.

30.19 Debugging Dash Apps

Dash's callback model makes debugging different from Streamlit. Some strategies:

debug=True during development: app.run(debug=True) enables hot reload (the app re-reads the file on save) and shows detailed error messages in the browser. Essential for iterative development.

Print statements in callbacks: anything you print appears in the terminal where you started the app. Useful for inspecting intermediate values.

Browser dev tools: open the browser's developer console to see JavaScript errors, network requests, and the full HTML tree. Useful for diagnosing styling issues and understanding what Dash is rendering.

The callback graph visualizer: run your app with debug=True and click the circular icon at the bottom-right. This opens a visualization of your callback dependency graph, showing which callbacks depend on which components. Invaluable for complex apps.

dash.callback_context: inside a callback, dash.callback_context.triggered tells you which input triggered the callback. Useful for callbacks that respond differently to different inputs.

@app.callback(
    Output("output", "children"),
    Input("button1", "n_clicks"),
    Input("button2", "n_clicks"),
)
def handle_clicks(n1, n2):
    ctx = dash.callback_context
    if not ctx.triggered:
        return "No button clicked"
    button_id = ctx.triggered[0]["prop_id"].split(".")[0]
    return f"Button {button_id} clicked"

Log levels: set logging.getLogger("dash").setLevel(logging.DEBUG) to see Dash's internal logs. Verbose but helpful when something unexplained happens.

Unit tests for callback logic: the best preventive measure. Pure functions are easy to test; make your callbacks call pure helper functions and test those directly.

30.20 A Note on Dash's Evolution

Dash has evolved significantly since its 2017 release. Some of the features in this chapter are relatively recent:

  • Pattern-matching callbacks: added in Dash 1.11 (2020).
  • use_pages multi-page: added in Dash 2.5 (2022). Before that, multi-page apps required manual URL routing.
  • Long callbacks: added in Dash 2.6 (2022).
  • Background callback manager: Dash 2.6+.
  • dash.html auto-discovery: earlier versions required import dash_html_components as html — the current from dash import html style is newer.

If you are reading older Dash tutorials or Stack Overflow answers, you may see the older syntax. The newer syntax is generally cleaner and is what current Dash documentation uses. When in doubt, consult the official docs for the current syntax.

The Plotly team continues to actively develop Dash, with new features shipping regularly. The callback model is stable, but the surface API continues to grow. For a book like this, we have tried to cover the most important features; for the full story, the official documentation is always the best reference.

30.21 A Deployment Example: Docker + Nginx + Gunicorn

For production Dash apps, a typical deployment stack is Docker + Nginx + Gunicorn. This combination handles concurrency, TLS termination, static asset serving, and process management. It is more complex than Streamlit Cloud but gives you full control.

Gunicorn is a Python WSGI server that runs your Dash app with multiple worker processes:

gunicorn app:server --workers 4 --bind 0.0.0.0:8050

The app:server reference is the Flask server object that Dash creates internally (server = app.server in your script). Gunicorn imports your module, finds the server, and runs it with multiple worker processes for concurrency.

Nginx sits in front of Gunicorn as a reverse proxy:

server {
    listen 443 ssl;
    server_name dashboard.mycompany.com;

    ssl_certificate /etc/letsencrypt/live/dashboard.mycompany.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/dashboard.mycompany.com/privkey.pem;

    location / {
        proxy_pass http://localhost:8050;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

Nginx handles TLS termination (so your app doesn't need to manage certificates), WebSocket upgrades (for Dash's live updates), and connection pooling. Let's Encrypt certificates are free and automatable via certbot.

Docker packages everything into a reproducible image:

FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8050
CMD ["gunicorn", "app:server", "--workers", "4", "--bind", "0.0.0.0:8050"]

Build with docker build -t my-dashboard . and run with docker run -p 8050:8050 my-dashboard. For production, you orchestrate Docker containers with Kubernetes, Docker Swarm, or a simpler cloud-hosted container service (AWS ECS, Google Cloud Run, Azure Container Apps).

This stack is standard for production Python web applications. It is more operationally complex than Streamlit Cloud (which handles everything for you), but it is the mature path for enterprise deployments where you need control over the environment.

30.22 Authentication Patterns

Dash apps without authentication are open to anyone who can reach the server. For internal or sensitive dashboards, you need to add an auth layer. Several patterns are common:

Reverse proxy auth: put an auth-enforcing proxy (OAuth2 Proxy, Authelia, nginx with auth_request) in front of your Dash app. The proxy handles the login flow, sets identity headers on authenticated requests, and blocks unauthenticated ones. Your Dash app sees only authenticated requests and can read user identity from the headers. This is the most production-grade pattern.

dash-auth library: a simple Dash add-on for basic authentication. Accepts a list of username/password pairs and adds an HTTP basic auth layer. Fine for quick internal dashboards; not appropriate for production because passwords are hashed weakly and there's no central management.

OAuth via Flask-Login: since Dash is built on Flask, you can use Flask's authentication ecosystem. Flask-Login provides session management, and libraries like Flask-OAuthlib or Authlib add OAuth providers (Google, GitHub, Okta, etc.). More setup but full flexibility.

Dash Enterprise auth: the commercial tier includes SSO, LDAP, and role-based access out of the box. Expensive but turnkey.

Token-based auth: for public dashboards with paid access, generate unique URLs with tokens that grant access for a limited time. Simple to implement, appropriate for sharing a specific dashboard with specific users.

For internal company dashboards, the reverse proxy pattern with OAuth2 Proxy is the sweet spot — it integrates with your SSO provider, it is free, and it is production-tested. Do not hand-roll auth for production dashboards; use a well-maintained library or proxy.

30.23 A Practical Migration Path from Streamlit to Dash

If you have a Streamlit app that has outgrown Streamlit's limitations, migrating to Dash is a realistic path. The rewrite takes time but is systematic. Here is a typical migration plan for a moderate-sized dashboard.

Step 1: Identify the components. Walk through the Streamlit app and list every widget, chart, and display element. Each becomes a Dash component in the new layout. Streamlit's st.sidebar.slider becomes dcc.Slider in a dbc.Col. st.selectbox becomes dcc.Dropdown. st.plotly_chart becomes dcc.Graph.

Step 2: Assign IDs. Every component in Dash needs an id so callbacks can reference it. Go through your list and give each component a descriptive ID: "year-slider", "region-dropdown", "main-chart", and so on.

Step 3: Identify the dependencies. For each chart or output in your Streamlit app, identify which widgets it depends on. In the Streamlit app, this is implicit in the script order. For Dash, you need to make it explicit. "The main chart depends on year and region" becomes an @app.callback(Output("main-chart", "figure"), Input("year-slider", "value"), Input("region-dropdown", "value")).

Step 4: Port the data logic. The pure Python functions (data loading, filtering, transformations) do not change. Lift them out of the Streamlit script into a module and import them into the Dash app. This step is usually trivial.

Step 5: Port the chart-building logic. The Plotly figures are the same in both frameworks. Copy the px.line(...) or go.Figure(...) calls from your Streamlit script into the Dash callbacks. The figures render identically in both.

Step 6: Handle caching. Streamlit uses @st.cache_data; Dash does not have a built-in equivalent. For expensive data loading, load globally (outside callbacks) — this is the Dash equivalent of caching for apps that do not need per-user data. For per-user data, use dcc.Store. For truly expensive operations, use flask_caching with Dash's server.

Step 7: Layout with Bootstrap. Streamlit's auto-layout is nice but limited. In Dash, use Dash Bootstrap Components (dbc.Container, dbc.Row, dbc.Col) to recreate the Streamlit layout. Usually more lines of code but with more control.

Step 8: Test the new app. Run both the Streamlit and Dash versions side-by-side, compare behavior, and verify the Dash version matches. Write unit tests for the callbacks.

Step 9: Add Dash-specific features. Once the basic functionality matches, add the features that motivated the migration: cross-filtering, auto-refresh, custom CSS, multi-page structure. These are what you gain by migrating.

Total effort: typically 1-3 days for a moderate dashboard (say, 200 lines of Streamlit). More for complex dashboards with many interactions. The effort pays off in the long run if you needed Dash's features; otherwise, stay in Streamlit.

30.24 A Final Comparison: Philosophical Differences

Streamlit and Dash are both good tools, but they have different philosophies that go beyond the surface API.

Streamlit's philosophy is "make the simple case trivial." The re-run model eliminates callbacks, the tight API reduces cognitive load, the free Community Cloud removes deployment friction. The goal is to turn "I have a Python script" into "I have an interactive app" with minimum friction. For that specific goal, Streamlit is unbeatable.

Dash's philosophy is "give the user full control." The explicit callback model exposes all the dependencies, the HTML/CSS integration supports any layout, the Dash Enterprise tier adds production features. The goal is to let users build any interactive app they can imagine, at the cost of more complexity. For production dashboards with specific requirements, Dash is usually better.

The two philosophies are not contradictory; they target different points on the complexity/flexibility curve. A mature data team uses both: Streamlit for prototypes and internal tools, Dash for production dashboards and customer-facing apps. Knowing when to use each is the skill; the libraries are just the implementation.

A final practical note: both frameworks are in active development, and specific features change over time. The comparison in this chapter reflects the state of both as of the writing. For the latest, consult the official documentation.

The broader point is that tool choice is a trade-off, not an absolute ranking. There is no "best" Python dashboard framework in the abstract; there is only the best fit for a given project. The same is true for visualization libraries generally: matplotlib is not better than Plotly, and neither is better than Altair. Each has a zone where it dominates. Learning multiple tools and knowing when to reach for each is more productive than picking one and using it for everything. The same discipline applies to Streamlit and Dash — learn both, build small projects in each, develop your intuition for which fits which kind of problem. That intuition is the real skill; the libraries are just where it gets applied.

One useful exercise: take a dashboard you built in Streamlit and port it to Dash (or vice versa). The port forces you to think about the implicit dependencies in the original and make them explicit in the target. You will learn more about both frameworks from this exercise than from reading ten chapters of documentation.

30.25 Check Your Understanding

  1. What are the two main parts of a Dash application?
  2. What is the difference between Input and State in a callback?
  3. How do you create a callback with multiple outputs?
  4. What is clickData and how is it used for cross-filtering?
  5. When should you use dcc.Interval?
  6. What are Dash Bootstrap Components and why are they useful?
  7. How do you create a multi-page Dash app?
  8. When should you choose Dash over Streamlit?

30.26 Chapter Summary

This chapter introduced Dash as the callback-driven alternative to Streamlit:

  • Architecture: layout (HTML + DCC components) + callbacks (reactive functions).
  • Layouts: nested html. and dcc. components with styles and ids.
  • Callbacks: @app.callback(Output, Input, State) to declare reactive dependencies.
  • Cross-filtering: clickData, selectedData, hoverData on charts drive callbacks.
  • Multi-page apps: dash.register_page and page_container.
  • Styling: Dash Bootstrap Components for polished responsive layouts.
  • Auto-refresh: dcc.Interval for periodic updates.
  • Dash vs Streamlit: explicit callbacks vs script re-run; choose based on complexity and interactivity needs.

The threshold concept — callbacks are reactive declarations — is the key shift from imperative thinking to dependency-based thinking. Once you accept that you are declaring relationships rather than writing event handlers, Dash's API becomes systematic and predictable.

Chapter 31 leaves interactive dashboards for automated reporting — Python scripts that generate PDFs, slides, and email reports on a schedule.

30.27 Spaced Review

  • From Chapter 29 (Streamlit): How does Dash's callback model differ from Streamlit's re-run model, and when is each better?
  • From Chapter 20-21 (Plotly): Dash uses Plotly for all its charts. How does Dash extend Plotly's interactivity?
  • From Chapter 19 (Multi-Variable Exploration): Shneiderman's brushing-and-linking is natural in Dash. How does it compare to Altair's linked views?
  • From Chapter 9 (Storytelling): A dashboard is a tool, not a story. How does Dash's structure affect the narrative?
  • From Chapter 4 (Honest Charts): Interactive filters let users customize views. How do you prevent misuse?

Dash is Python's most capable open-source dashboard framework. Its explicit callback model is harder to learn than Streamlit's re-run model, but it scales to more complex applications and supports features (cross-filtering between charts, auto-refresh for live data, enterprise integration, and full CSS styling) that Streamlit does not. For production dashboards with sophisticated requirements — multiple interacting charts, custom styling, enterprise authentication, real-time updates — Dash is usually the right choice. For quick prototypes and internal tools, Streamlit is often faster. Knowing both frameworks well, and knowing exactly when to reach for each particular one in a given situation, is what a mature Python dashboard practitioner brings to any serious project. Chapter 31 leaves interactive dashboards for the complementary topic of automated report generation — Python scripts that produce PDFs, slides, and emails on schedules, for audiences who want the answer delivered rather than the tool to explore it themselves. Dashboards and reports are two sides of the same production story: dashboards serve users who want to ask their own questions, and reports serve users who want the answers pushed to them at regular intervals without any effort on their part.