| name | marimo-authoring |
| description | Use when authoring, editing, or debugging marimo notebooks (.py files containing @app.cell decorators or import marimo as mo). Covers reactive execution, cell anatomy, PEP 723 inline dependencies, mo.ui widgets, SQL cells via mo.sql, plotting rules, script mode, pytest integration, sharing/WASM export, and the `marimo check` lint contract, plus the Jupyter differences that trip up new users. |
Authoring marimo notebooks
Marimo is a reactive Python notebook. Cells re-run automatically when their inputs change. Unlike Jupyter, there is no hidden state and no global-by-default scoping: each cell explicitly declares the names it produces.
Notebooks are saved as pure Python files (notebook.py), not JSON. They diff cleanly in git.
The mental model in three rules
- A cell's outputs are its return value. If a cell defines
x and y that other cells use, the cell must return (x, y).
- A cell's inputs are its free variables. Marimo parses the cell, finds names not defined locally, and treats them as inputs. The runtime re-runs the cell when any input's value changes.
- There are no globals across cells. A name defined inside a cell is local to that cell unless it's returned.
Together, these mean: edit a cell that produces x, and every downstream cell that uses x automatically re-executes. No "Run All", no out-of-order surprises.
Cell anatomy
import marimo
__generated_with = "0.x.x"
app = marimo.App(width="medium")
@app.cell
def _():
import marimo as mo
return (mo,)
@app.cell
def _(mo):
slider = mo.ui.slider(0, 100, value=50, label="Speed")
slider
return (slider,)
@app.cell
def _(slider):
speed = slider.value * 2
return (speed,)
@app.cell
def _(mo, speed):
mo.md(f"**Speed:** {speed} mph")
return
if __name__ == "__main__":
app.run()
Notice:
- The file starts with a PEP 723 inline-script header declaring every dependency. The notebook is self-contained:
uv run notebook.py and uvx marimo edit --sandbox both work from it.
- Each cell is
def _(...), single underscore, decorated with @app.cell. Marimo manages signatures and returns; don't hand-pick fancy names.
- Function args = the cell's inputs. Exports are parenthesized tuples:
return (slider,).
- Put all imports in the first cell, including
import marimo as mo.
- Only the final expression of a cell renders.
return produces no output; indented or conditional expressions don't render. For conditional output, assign to a variable in each branch and put the bare variable last.
Hard rules (marimo check flags these)
- One owning cell per name. No redefining the same name in two cells.
- No
if guards around cell bodies, no try/except as control flow. Reactivity handles re-execution; only catch genuinely expected exceptions.
- Don't mutate objects across cells. Mutation is invisible to the dependency graph; derive a new name instead (
df2 = df.drop(...), not df.drop(..., inplace=True) in another cell).
- Underscore-prefixed names (
_tmp) are cell-local and invisible to other cells. Keep to ≤ 2 per cell; prefer real names.
- Don't read a widget's
.value in the cell that defines it. Read it one cell downstream.
- Avoid
mo.state() unless you genuinely need bidirectional widget sync; plain reactivity covers nearly everything.
mo.ui widgets
Widgets are reactive values. Reading .value in a cell makes that cell depend on the widget.
Common widgets:
mo.ui.slider(0, 100, value=50, label="x")
mo.ui.number(value=0, step=0.1)
mo.ui.text(value="hello", placeholder="name")
mo.ui.dropdown(options=["a", "b", "c"], value="a")
mo.ui.checkbox(value=True, label="enabled")
mo.ui.file(filetypes=[".csv"])
mo.ui.button(label="run")
mo.ui.refresh(default_interval=1.0)
Compose with mo.ui.array([...]) (value is a list) or mo.ui.dictionary({"alpha": mo.ui.slider(0, 1), ...}) (value is a dict).
mo.md and layout
mo.md("# Heading\nSome **bold** text.")
mo.md(f"x = {x}")
mo.callout(mo.md("..."), kind="info")
mo.hstack([a, b]); mo.vstack([a, b])
mo.tabs({"Tab 1": content_a, "Tab 2": content_b})
mo.accordion({"Section": content})
SQL cells
df = mo.sql(
f"""
SELECT a, COUNT(*) AS n FROM tbl GROUP BY a
"""
)
Assign the result to a dataframe. No comments inside the SQL string. Pass engine=my_engine for non-DuckDB engines.
Plots
- matplotlib: build the plot, end the cell with
plt.gca(), never plt.show().
- plotly / altair: make the figure/chart object the cell's final expression.
Script mode
if mo.app_meta().mode == "script":
data = load_full_dataset()
else:
data = sample_dataset()
Swap data sources by mode; keep UI code identical and widgets always visible. uv run notebook.py executes the notebook top-to-bottom as a script.
Testing
Functions named test_* defined in cells are auto-discovered: pytest notebook.py just works. Add pytest to the PEP 723 header.
The contract: marimo check
After every edit to a marimo file:
uvx marimo check --fix notebook.py
--fix rewrites what it can and exits 0 regardless of remaining warnings, so resolve the leftover findings by hand. To make warnings fail (exit nonzero) and gate CI, run --strict. It catches the marimo-specific breakage plain linters miss: cross-cell redefinitions, cycles, formatting drift. --format=json emits machine-readable findings (the default is full). In Claude Code this plugin runs it automatically via a PostToolUse hook; elsewhere (e.g. pi), run it yourself.
The Jupyter → marimo gotchas
If you're translating from Jupyter, these trip people up:
- No
display(x): just write x as the last expression.
- No
%matplotlib inline: end the cell with plt.gca() (or the figure object).
- No mutable global dicts shared across cells: marimo will error on multi-cell definitions of the same name. Pick one owning cell.
del doesn't help: to "reset" state, restart the kernel via the UI.
- No cell-order surprises: execution order follows dependencies, not visual order. If you need a side effect at startup, return a marker value and depend on it.
Editing
uvx marimo edit --sandbox notebook.py --watch: preferred. Isolated venv built from the PEP 723 header, live-reloads as the file changes on disk. Plain fallback: marimo edit notebook.py.
marimo convert old_notebook.ipynb -o new_notebook.py: one-shot Jupyter conversion (then run marimo check and audit). Without -o, marimo convert prints to stdout and writes no file.
Sharing and deployment
marimo run notebook.py: read-only app mode, code hidden. Use for dashboards.
marimo export html-wasm notebook.py -o site/: static site that runs entirely in the browser via WASM (not all packages are WASM-compatible).
uv run notebook.py: plain script execution; the PEP 723 header supplies the deps.
Performance and large data
- Cells re-run when inputs change, not on every interaction. To gate a heavy cell behind a click, use
mo.ui.run_button() with mo.stop(not btn.value). A plain mo.ui.button() only changes value if given on_click (e.g. value=0, on_click=lambda v: v + 1).
- Use
mo.ui.refresh for explicit polling cells.
- For very large data, load once in a cell with no widget dependencies; downstream cells filter/transform.
mo.stop(condition, output) short-circuits a cell early, useful when a widget hasn't been touched yet.
When to choose marimo over Jupyter
- Notebooks re-run by other people (reactive guarantees reproducibility), deployed as small apps (
marimo run), or under version control (clean diffs).
- Stay with Jupyter for rich third-party widgets that haven't ported, or throwaway scratch where reactive re-execution gets in the way.