As a Dash app grows, it is easy to get lost in the complexity of interconnected, large callbacks handling a lot of functionality. How can you keep your app manageable? What are the best practices when it comes to Dash callbacks?
Over the years, I’ve developed and maintained apps either on my own or with different teams. In this tutorial, I’ll share five good practices, based on my experience, to help you better organize your Dash callbacks.
See also: Dash development best practices
Let’s dive in! 😄
1. Separate layouts from callbacks
It’s really common to see callbacks, layouts, and data processing in the same file. Many people develop the first version of their app quickly, and it’s too early to think about organization. However, if your plan is to make the application easy to maintain, you must adopt a better project structure.
There are many ways to achieve this. I personally follow something that resembles the MVC pattern:
- Create new folders:
callbacks
,layouts
, andutils
. - Create separate files for each part of your app in the
callbacks
andlayouts
folders.
See more: How to organize a large Dash app.
Example
The following is an example architecture. The one that I personnaly use in my projects is very similar:
# Example folder structure
app/
├── callbacks/
│ ├── sales_dashboard.py # All sales-related callbacks
│ ├── inventory.py # All inventory-related callbacks
│ └── user_settings.py # User preferences callbacks
├── layouts/
│ ├── sales_dashboard.py # Sales dashboard layout
│ ├── inventory.py # Inventory management layout
│ └── user_settings.py # Settings page layout
├── utils/
│ └── data_utils.py # Data preprocessing functions
└── app.py
In the app.py
file, the code becomes more readable:
# app.py
from dash import Dash
from layouts.sales_dashboard import get_sales_dashboard_layout
from layouts.inventory import get_inventory_layout
from layouts.user_settings import get_user_settings_layout
from callbacks.sales_dashboard import *
from callbacks.inventory import *
from callbacks.user_settings import *
app = Dash(__name__)
app.layout = html.Div([
get_sales_dashboard_layout(),
get_inventory_layout(),
get_user_settings_layout(),
])
# Run the app
if __name__ == '__main__':
app.run_server(debug=True)
With this architecture, callbacks centralize the logic of each part of the app. They rely on utils
to load and process data and layouts
functions to update graphs, tables, etc.
See below for an example callback in callbacks/sales_dashboard.py
.
2. Make your callbacks readable
It’s common to start doing everything within the callback. This results in many callbacks with 10-50 lines of code, making them hard to read. A good rule of thumb is to keep a callback concise and clear.
You should be able to understand what the callback does just by looking at the inputs, outputs, and a few lines of code (as in the previous example).
Example
Here’s an example. How much time do you need to understand the following callback?
# Hard-to-read callback - too much happening
@app.callback(
Output('sales-graph', 'figure'),
[Input('date-picker', 'value'),
Input('product', 'value')]
)
def update_graph(date, product):
# Load data
df = pd.read_csv('sales.csv')
# Complex data processing inside callback
df['date'] = pd.to_datetime(df['date'])
df = df[df['date'] >= date]
df = df[df['product'] == product]
# Calculate metrics
total_sales = df['amount'].sum()
avg_sale = df['amount'].mean()
# Create figure with multiple traces
fig = go.Figure()
fig.add_trace(go.Scatter(x=df['date'], y=df['amount'], name='Sales'))
fig.add_trace(go.Scatter(x=df['date'], y=df['amount'].rolling(7).mean(), name='7-day average'))
# Complex layout settings
fig.update_layout(
title=f'Sales for {product}: Total ${total_sales:,.2f}, Average ${avg_sale:.2f}',
xaxis_title='Date',
yaxis_title='Amount',
hovermode='x unified'
)
return fig
I’m pretty sure it took some time. Now let’s outsource each part into functions saved in other files (following advice #1):
# callbacks/sales_dashboard.py
from layouts.sales_dashboard import create_sales_figure
from utils.data_utils import load_and_filter_data
@app.callback(
Output('sales-graph', 'figure'),
[Input('date-picker', 'value'),
Input('product', 'value')]
)
def update_graph(date, product):
df = load_and_filter_data(date, product)
fig = create_sales_figure(df)
return fig
Now, how much time did it take you to understand this code? 😃
The callback function doesn’t even need to be commented. The code speaks for itself, is easily understandable, and is easy to maintain. Future developers on the project will immediately understand what is going on!
3. Don’t oversplit callbacks
I generally advocate for a “divide-and-conquer” approach, which encourages making small, atomic functions. However, in the case of callbacks, it’s not always the best option. Making small callbacks increases the number of HTTP requests needed, thus increasing latency and unnecessary server rounds.
Example
Here’s an example. The first version splits a simple calculation into two callbacks, creating unnecessary complexity:
# Unnecessarily split version
@app.callback(
Output('temp-celcius', 'children'),
Input('temperature', 'value')
)
def convert_to_celsius(fahrenheit):
celsius = (fahrenheit - 32) * 5/9
return f"{celsius:.1f}°C"
@app.callback(
Output('temp-kelvin', 'children'),
Input('temp-celcius', 'children')
)
def convert_to_kelvin(celsius_text):
celsius = float(celsius_text.replace('°C', ''))
kelvin = celsius + 273.15
return f"{kelvin:.1f}K"
Let’s rewrite this as a single callback:
# Better as one callback
@app.callback(
[Output('temp-celcius', 'children'),
Output('temp-kelvin', 'children')],
Input('temperature', 'value')
)
def convert_temperature(fahrenheit):
celsius = (fahrenheit - 32) * 5/9
kelvin = celsius + 273.15
return f"{celsius:.1f}°C", f"{kelvin:.1f}K"
This version combines the conversions into a single callback. It simplifies the code, eliminates the need for string parsing, and reduces the number of callback executions. The calculations are closely related and simple enough that splitting them provides no benefit.
4. Avoid big callbacks too
On the other hand, making big callbacks that handle everything isn’t a good option either. But how do you know if a callback should be split or restructured?
A good rule of thumb is to aim for callbacks with inputs and outputs that naturally group together, i.e., share the same purpose.
Example
Here’s an example. This callback handles both the update of a chart and the creation of an alert:
# Bad example: One callback doing too many unrelated things
@app.callback(
[Output('sales-chart', 'figure'),
Output('alerts', 'children')],
[Input('date-range', 'value'),
Input('threshold', 'value')]
)
def update_everything(date_range, threshold):
# This callback mixes two very different responsibilities:
# 1. Sales visualization
# 2. Alert system for unusual patterns
sales_data = get_sales_data(date_range)
# Create sales visualization
fig = create_sales_chart(sales_data)
# Also handle complex alert logic
unusual_patterns = detect_anomalies(sales_data, threshold)
alert_messages = generate_alert_messages(unusual_patterns)
return fig, alert_messages
Now let’s split this callback into two, each with a separate purpose:
# Better: Split into two callbacks with clear, separate purposes
@app.callback(
Output('sales-chart', 'figure'),
Input('date-range', 'value')
)
def update_sales_chart(date_range):
# Only handles visualization
sales_data = get_sales_data(date_range)
return create_sales_chart(sales_data)
@app.callback(
Output('alerts', 'children'),
[Input('date-range', 'value'),
Input('threshold', 'value')]
)
def update_alerts(date_range, threshold):
# Only handles the alert system
sales_data = get_sales_data(date_range)
unusual_patterns = detect_anomalies(sales_data, threshold)
return generate_alert_messages(unusual_patterns)
Even though the original code avoids repetition and combines a common input (date-range
), it is badly designed. Poor design will eventually complicate debugging. The split version brings the following benefits:
- Each callback has its own clear purpose → easier to add functionalities in the future.
- Debugging one callback is easier as it has no impact on the other.
- Visualization can be updated without re-triggering the alert logic, and vice versa.
The key takeaway is that callbacks should be split when they handle logically separate features, not just to make them smaller.
5. Chain callbacks intelligently
The way we chain callbacks in Dash Plotly can sometimes resemble the goto
instruction in programming languages like C. The goto
statement allows the program to jump arbitrarily from one part of the code to another, a practice often criticized for leading to “spaghetti code“—code that is difficult to debug, understand, and maintain.
Dash’s callback system, while powerful, can introduce similar challenges if not carefully managed. As callbacks depend on specific input-output relationships, a poorly structured app can quickly become tangled and confusing, with callbacks triggering each other in complex and unintended ways.
Here is a list of ideas to avoid this:
- If you follow advice n°2, try to avoid chaining callbacks from a callback file to another callback file.
- Avoid connecting a bottom component with a top callback. Instead, try to compute the information of this component upfront.
- As the code changes, some inputs may not be required anymore for a callback. Removing useless inputs will reduce the overall complexity of the app.
- Changing an
Input
to aState
when it’s not needed can as well reduce the complexity of an app.
Example
Let’s see take a look at this code :
# Poor callback organization - "spaghetti" chaining
# callbacks_layout1.py
@app.callback(
Output('top-chart', 'figure'),
Input('date-picker', 'value')
)
def update_top_chart(date):
return create_top_figure(date)
# callbacks_layout2.py
@app.callback(
Output('middle-stats', 'children'),
Input('top-chart', 'clickData')
)
def update_middle_stats(click_data):
point = click_data['points'][0]
return calculate_stats(point)
# callbacks_layout3.py
@app.callback(
Output('bottom-table', 'data'),
Input('middle-stats', 'children')
)
def update_bottom_table(stats):
# Fragile: depends on parsing text from middle component
value = float(stats.split(': ')[1])
return get_table_data(value)
The first version creates a chain of dependencies across different files, where each component waits for updates from the previous one. This makes the code fragile (parsing text from UI elements), hard to debug (dependencies spread across files), and inefficient (cascading updates).
The improved version:
# Better organization - reduce dependencies, compute data upfront
# callbacks.py
def get_full_data(date):
"""Compute all required data at once"""
df = load_data(date)
return {
'chart_data': prepare_chart_data(df),
'stats': calculate_stats(df),
'table_data': prepare_table_data(df)
}
@app.callback(
[Output('top-chart', 'figure'),
Output('middle-stats', 'children'),
Output('bottom-table', 'data')],
Input('date-picker', 'value')
)
def update_dashboard(date):
data = get_full_data(date)
return (
create_top_figure(data['chart_data']),
format_stats(data['stats']),
data['table_data']
)
# If click interaction is needed, keep it separate
@app.callback(
Output('detail-view', 'children'),
Input('top-chart', 'clickData')
)
def show_details(click_data):
if click_data is None:
return "Click a point for details"
The improved version front-loads data processing and groups related callbacks together. By streamlining dependencies and keeping data flow clear, while separating interactive features, the code becomes more maintainable.
6. Other good practices
Avoid using suppress_callback_exceptions
I think it’s not a good practice to use suppress_callback_exceptions
. If a component is missing but shouldn’t be, this should raise an error. Explicit errors help you understand the problem more quickly. Implicit errors will waste your time.
If you can’t avoid setting suppress_callback_exceptions=True
, it likely means you need to rethink your app layout. You could rely on pattern-matching callbacks for components created at runtime.
Use prevent_initial_call
when possible
Dash executes callbacks at least once during app initialization by default. If a callback doesn’t need to be executed at first, you can disable its initial execution with prevent_initial_call
. This reduces the number of initial callbacks triggered during loading.
Use PreventUpdate
PreventUpdate
is a great way to catch errors, e.g., when a value is None
or isn’t supposed to be what it is.
Using PreventUpdate
helps stop unnecessary callback chaining, reducing the number of updates and HTTP requests. This is a performance tip, but it also simplifies debugging.
Use dcc.Store
You can sometimes use dcc.Store
to compute and store results. If the stored data is based on inputs, you instantly reduce the number of inputs needed for dependent callbacks, making them more readable.
However, avoid saving heavy data in dcc.Store
(e.g., keep it under 1 MB). Large data slows down the app because it must be both downloaded and uploaded from the client.
Conclusion
What you need to remember is that half the work of keeping callbacks easy to understand is using a well-organized structure.
- Don’t wait too long to refactor your callback functions. If they get too big, externalize functions to make the callbacks easier to read.
- Don’t oversplit logic into many callbacks, but avoid processing everything in a single callback with all inputs and outputs either. Remember the general rule of thumb to create “natural groups”.
Everything presented in this article isn’t an absolute truth. As the framework evolves, so do best practices. But this should help you grow your app from a simple single-page dashboard into a full interactive platform.
I hope you learned something from this article! 🙂 Be sure to subscribe to the newsletter to see the next ones.