Skip to content

[BUG] Callbacks double-registered when using uvicorn --reload under backend="fastapi" #3818

@essol-li

Description

@essol-li

Context

  • Python 3.12.13
  • uvicorn 0.49.0
  • Linux (Docker: python:3.12-slim)
  • Package version:
dash                    4.2.0

Description

When using Dash(backend="fastapi") with uvicorn's --reload flag for development, all callbacks are registered twice after the first file-change reload. This produces Duplicate callback outputs and Overlapping wildcard callback outputs errors.

Root cause: GLOBAL_CALLBACK_LIST and GLOBAL_CALLBACK_MAP in dash._callback are module-level globals that persist across uvicorn's reload cycle. When uvicorn detects a file change, it calls importlib.reload() on the application module within the same process. This re-executes all @callback decorators, appending duplicates to the never-cleared global lists.

This did not happen with the Flask backend because Werkzeug's reloader spawns a fresh subprocess (clean sys.modules, clean globals). Uvicorn's reloader operates in-process via importlib.reload().

Example:

This code below:

import os
from dash import Dash, html, dcc, callback, Output, Input

app = Dash(__name__, backend="fastapi")
app.layout = html.Div([
    html.H1("Reload Bug Demo"),
    dcc.Input(id="input", value="Hello"),
    html.Div(id="output"),
])

@callback(Output("output", "children"), Input("input", "value"))
def update(value):
    return f"You typed: {value}"
 
app.enable_dev_tools(debug=True, dev_tools_hot_reload=True)

server = app.server

if __name__ == "__main__":
    import uvicorn
    uvicorn.run("app:server", host="0.0.0.0", port=8050, reload=True)

Run with:

pip install dash[fastapi]==4.2.0
python app.py

Will result the following error:

In the callback for output(s):
  output.children
Output 0 (output.children) is already in use.
To resolve this, set `allow_duplicate=True` on duplicate outputs, or combine the outputs into one callback function, distinguishing the trigger by using `dash.callback_context` if necessary.

Currently I have to manually clearing the private globals before registering callbacks:

from dash._callback import GLOBAL_CALLBACK_LIST, GLOBAL_CALLBACK_MAP
GLOBAL_CALLBACK_LIST.clear()
GLOBAL_CALLBACK_MAP.clear()

This works but relies on private internals (dash._callback) that could break in any future release.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions