Marimo: Reactive, Reproducible Python Notebooks
Marimo is a Python notebook that stores its content as a plain .py file and runs cells reactively, much like a spreadsheet recalculates dependent formulas. It was designed to address recurring pain points in traditional notebook workflows, especially hidden state and out-of-order execution. This tutorial walks through what Marimo is, why its model differs from Jupyter, and how to build an interactive data-exploration notebook that also runs as a script and as a web app.
Why Another Notebook?
Notebooks are popular for exploration, teaching, and reporting. They are also notorious for a specific class of bugs that come from how the classic notebook model works. Before looking at Marimo's features, it helps to name the problems it tries to solve.
Hidden State
In a typical Jupyter session, the kernel keeps every variable you ever defined, even after you delete the cell that created it. A notebook can appear to work simply because a now-deleted cell once ran. Reopen it later, run top to bottom, and it breaks. The visible code no longer matches the runtime state.
Out-of-Order Execution
Jupyter lets you run cells in any order. The In [n] counter records the order you happened to use, not a reproducible sequence. Two people running the same notebook can reach different results depending on which cells they ran and when.
Poor Diffs and Version Control
A .ipynb file is JSON that embeds source code, execution counts, and base64-encoded outputs (images, tables) in one document. A one-line code change can produce a large, noisy diff. Reviewing a notebook pull request is awkward, and merge conflicts are common.
Hard to Reuse
Turning a notebook into a script or a module usually means copying cells into a .py file and untangling the execution order by hand. The notebook itself is not directly importable or runnable as a program.
Marimo takes a different position on each of these points. The next sections explain how.
Installation and First Steps
Marimo is a standard Python package.
pip install marimo
Confirm the install and check the version:
marimo --version
Create a new notebook:
marimo new
Open an existing notebook in the editor:
marimo edit notebook.py
The editor runs in your browser but the notebook file lives on disk as ordinary Python. If you already have Jupyter notebooks, convert them:
marimo convert oldanalysis.ipynb > newanalysis.py
To serve a notebook as a read-only interactive application instead of an editable document:
marimo run notebook.py
In run mode the code cells are hidden and only the UI and outputs are shown. The same file is the editor document, the app, and a script. There is no separate export step to get a working program.
The .py File Format
A Marimo notebook is a regular Python file. Each cell is a function decorated with @app.cell, and the file ends with a small runner block. A minimal notebook looks like this:
import marimo
app = marimo.App()
@app.cell
def ():
import marimo as mo
return (mo,)
@app.cell
def (mo):
x = 21
mo.md(f"x is {x}")
return (x,)
if name == "main":
app.run()
Two things are worth noticing. First, this is valid Python that you can lint, format with tools like Black or Ruff, and review in a normal diff. A code change shows up as a code change, not as a blob of JSON. Second, the function arguments and return values are not boilerplate you maintain by hand. Marimo generates them from the variables each cell reads and defines. Those declarations are exactly what powers the reactive model.
The Reactive Execution Model
This is the central idea in Marimo and the clearest break from Jupyter.
Cells Form a Dependency Graph
Marimo statically analyzes each cell to see which variables it defines and which it references. From that, it builds a directed acyclic graph (DAG). A cell that reads df depends on the cell that defines df. When you run or change a cell, Marimo runs that cell and every cell downstream of it, in topological order. Cells that do not depend on the change are left alone.
The practical effect: there is no "run order" to remember. The order of cells in the file does not determine execution; the data dependencies do. You can place cells in whatever reading order makes the document clear.
A Variable Is Defined in Exactly One Cell
Marimo enforces that each global variable is assigned in only one cell. If two cells both define model, Marimo reports an error instead of silently letting the last-run cell win. This rule is what makes the DAG well-defined and reproducible. It feels restrictive at first, but it removes a whole category of "which cell set this?" confusion. When you need a throwaway name in several places, use a local variable (prefix it with an underscore, e.g. tmp) so it stays private to the cell.
No Hidden State
Because Marimo derives state from the current cells, deleting a cell also removes the variables it defined, and every dependent cell updates immediately. There is no lingering value from code that no longer exists. What you see in the file is what runs. Reopening the notebook and the result you had are the same thing, because the result is a function of the code, not of your click history.
A Quick Comparison
Consider two cells:
@app.cell
def ():
baseprice = 100
return (baseprice,)
@app.cell
def (baseprice):
total = baseprice 1.11
total
return (total,)
Change baseprice to 120 in the first cell and the second cell recomputes on its own, showing the new total. In Jupyter you would have to remember to rerun the second cell. In Marimo, forgetting is not possible, because the relationship is part of the graph.
UI Elements That Bind Reactively
Marimo ships interactive widgets under mo.ui. The important difference from typical notebook widgets is that a UI element's .value is an ordinary variable in the graph. Reading .value in another cell makes that cell depend on the widget, so moving a slider reruns exactly the cells that use it.
@app.cell
def (mo):
npoints = mo.ui.slider(start=10, stop=1000, value=200, step=10, label="Points")
noise = mo.ui.slider(start=0.0, stop=2.0, value=0.3, step=0.05, label="Noise")
mo.hstack([npoints, noise])
return npoints, noise
@app.cell
def (npoints, noise, np):
rng = np.random.defaultrng(0)
x = np.linspace(0, 10, npoints.value)
y = 2.0 x + 1.0 + rng.normal(0, noise.value, size=npoints.value)
return x, y
When either slider moves, the cell that builds x and y reruns, and so does anything downstream of it. You never wire up a callback; the dependency is implicit in the fact that you read npoints.value and noise.value.
Other common elements work the same way:
@app.cell
def (mo):
method = mo.ui.dropdown(
options=["least squares", "ridge"], value="least squares", label="Fit method"
)
label = mo.ui.text(value="experiment-1", label="Run label")
return method, label
@app.cell
def (mo):
upload = mo.ui.file(label="Upload a CSV", filetypes=[".csv"])
upload
return (upload,)
mo.ui.table renders a dataframe with selection support, and its .value holds the selected rows so a downstream cell can react to a user's selection.
Displaying Output and Markdown
The last expression in a cell is its output, similar to Jupyter. For text and documentation, mo.md renders Markdown and supports f-string interpolation, including live UI values:
@app.cell
def (mo, npoints, noise):
mo.md(
f"""
## Synthetic dataset
Generated {npoints.value} points with noise standard
deviation {noise.value:.2f}.
"""
)
return
Because this cell reads the slider values, the rendered text updates as you drag the sliders. You can embed a widget directly inside Markdown by interpolating the object, which is convenient for inline controls.
Layout
Marimo provides layout helpers that compose outputs:
@app.cell
def (mo, npoints, noise, method):
mo.vstack(
[
mo.md("### Controls"),
mo.hstack([npoints, noise]),
method,
]
)
return
Use mo.hstack for a row, mo.vstack for a column, and mo.ui.tabs to group sections:
@app.cell
def (mo, chart, summarytable):
mo.ui.tabs({"Chart": chart, "Data": summarytable})
return
A Coherent Example: Interactive Linear Fit
Putting the pieces together, here is a small model demo. Sliders control the data, a dropdown picks the fitting method, and a chart updates reactively. The cells below would each be an @app.cell function in the notebook file; they are shown as cell bodies for readability.
# imports
import marimo as mo
import numpy as np
import polars as pl
import altair as alt
# controls
npoints = mo.ui.slider(10, 1000, value=200, step=10, label="Points")
noise = mo.ui.slider(0.0, 2.0, value=0.3, step=0.05, label="Noise")
method = mo.ui.dropdown(["least squares", "ridge"], value="least squares", label="Method")
mo.hstack([npoints, noise, method])
# data generation
rng = np.random.defaultrng(0)
x = np.linspace(0, 10, npoints.value)
y = 2.0 x + 1.0 + rng.normal(0, noise.value, size=npoints.value)
data = pl.DataFrame({"x": x, "y": y})
# fit the model
if method.value == "ridge":
lam = 1.0
X = np.vstack([x, np.oneslike(x)]).T
coef = np.linalg.solve(X.T @ X + lam np.eye(2), X.T @ y)
else:
coef = np.polyfit(x, y, 1)
slope, intercept = float(coef[0]), float(coef[1])
yfit = slope x + intercept
# chart
fitted = data.withcolumns(pl.Series("yfit", yfit))
points = alt.Chart(fitted.topandas()).markcircle(opacity=0.5).encode(x="x", y="y")
line = alt.Chart(fitted.topandas()).markline(color="red").encode(x="x", y="yfit")
chart = mo.ui.altairchart(points + line)
chart
# live summary
mo.md(
f"""
Method: {method.value}
Slope: {slope:.3f} Intercept: {intercept:.3f}
Samples: {npoints.value}
"""
)
Drag a slider or change the dropdown and the data, fit, chart, and summary recompute in dependency order, with no manual reruns. The same notebook is also a working Python program, as the next section shows.
Running as a Script
Because the file is plain Python with an app.run() entry point, you can execute it directly:
python notebook.py
This runs every cell once, top of the graph to bottom, with no editor. It is useful in batch jobs, cron tasks, or CI. UI elements take their default values when there is no interactive session, so design defaults that make sense for unattended runs.
Command-Line Arguments and Parameterization
To make a script configurable, read CLI arguments with mo.cliargs:
@app.cell
def (mo):
args = mo.cliargs()
seed = int(args.get("seed", 0))
outpath = args.get("out", "results.csv")
return seed, outpath
Pass values after a -- separator:
python notebook.py -- --seed 42 --out run42.csv
The same arguments are available when serving with marimo run, so one notebook can be parameterized consistently across script and app modes.
Running as a Web App and Deploying
marimo run notebook.py serves the notebook as an interactive app with code hidden. You can host it like any web service:
marimo run notebook.py --host 0.0.0.0 --port 8080
For deployment, run that command behind a process manager or in a container. Marimo is also a regular Python process, so the usual options (a systemd unit, a Docker image, a platform-as-a-service worker) all apply.
Browser-Only Apps with WASM
Marimo can export a notebook to a self-contained HTML file that runs entirely in the browser via WebAssembly, with no server:
marimo export html-wasm notebook.py -o site/index.html --mode run
Host the resulting file on any static host. Use --mode edit to ship an editable WASM notebook instead of a read-only app. This is a good fit for documentation sites, demos, and teaching material where you do not want to run a backend.
SQL Cells and DataFrame Integration
Marimo has first-class support for dataframes and SQL. You can mix Polars, pandas, and DuckDB in the same notebook, and a SQL cell can query a dataframe directly:
@app.cell
def (mo, data):
result = mo.sql(
"""
SELECT
round(x) AS xbucket,
avg(y) AS meany,
count() AS n
FROM data
GROUP BY xbucket
ORDER BY xbucket
"""
)
return (result,)
The query runs through DuckDB, references the data dataframe by name, and returns a dataframe. Because the SQL cell reads data, it is part of the same reactive graph: regenerate the data and the query reruns. In the editor, Marimo also offers a SQL cell type so you can write queries without the mo.sql(...) wrapper.
Caching Expensive Cells and Stopping Early
For slow computations, mo.cache memoizes a function based on its inputs, so reruns triggered by unrelated changes do not recompute it:
@app.cell
def (mo, np):
@mo.cache
def expensivefit(seed: int, size: int):
rng = np.random.defaultrng(seed)
sample = rng.normal(size=size)
return float(sample.mean()), float(sample.std())
return (expensivefit,)
For results that should survive a kernel restart, use Marimo's persistent cache, which stores entries on disk:
@app.cell
def (mo, np):
with mo.persistentcache(name="sampling"):
big = np.random.defaultrng(0).normal(size=10000000)
bigmean = float(big.mean())
return (bigmean,)
To short-circuit a cell, mo.stop halts execution (and the cells downstream of its output) when a condition holds. This is handy for guarding against missing input:
@app.cell
def (mo, upload):
mo.stop(upload.value is None, mo.md("Upload a CSV to continue."))
rawbytes = upload.value[0].contents
return (rawbytes,)
When the file is missing, the cell shows the message and stops, and dependent cells do not run with invalid data.
Testing Notebooks with pytest
Since a notebook is importable Python, you can test it. Define plain functions inside cells and import them in a test module, or run cells directly. A test cell whose function name starts with test is also collected by pytest when you point it at the file:
@app.cell
def (expensivefit):
def testfitisdeterministic():
assert expensivefit(1, 100) == expensivefit(1, 100)
return (testfitisdeterministic,)
pytest notebook.py
This means the same notebook can serve as exploration, app, and a place to keep regression tests close to the code they cover.
Best Practices
Keep one responsibility per cell. Small cells make the dependency graph clear and reruns cheap, and they read well in diffs.
Prefer descriptive global names for values that cross cells, and underscore-prefixed locals for scratch variables. This keeps the single-definition rule from getting in your way.
Choose sensible defaults for every UI element. Defaults are what script mode and unattended runs use, so they should produce a valid result on their own.
Cache at the function boundary, not around large blocks. mo.cache keys on arguments, so pass the inputs explicitly rather than relying on closures over notebook globals.
Use mo.stop to guard cells that depend on uploads, network calls, or other inputs that may be absent, so the graph fails gracefully instead of producing garbage.
Commit the .py file and review it like code. Because the diff is real Python, code review and CI work the way they do for the rest of your project.
Marimo vs. Jupyter at a Glance
Storage: Marimo uses a plain .py file; Jupyter uses .ipynb JSON with embedded outputs.
Execution: Marimo reruns dependent cells automatically through a dependency graph; Jupyter runs cells manually in whatever order you click.
State: Marimo derives state from current cells and removes variables when a cell is deleted; Jupyter retains hidden state from deleted or out-of-order cells.
Reuse: A Marimo notebook runs as a script, serves as an app, and imports as a module; a Jupyter notebook needs conversion to become a program.
Version control: Marimo diffs are ordinary code diffs; Jupyter diffs are noisy JSON.
Interactivity: In Marimo, UI values are graph variables, so widgets update outputs without callbacks; Jupyter widgets need explicit observers or manual reruns.
This is not a claim that Marimo replaces Jupyter for every task. Jupyter has a vast ecosystem and many users prefer its free-form execution for quick scratch work. The trade Marimo makes is to accept a few rules, mainly single-definition and reactivity, in exchange for reproducibility and reusability.
Conclusion and Key Takeaways
Marimo reframes the notebook as a reactive program stored as plain Python. The dependency graph removes out-of-order execution, the single-definition rule and derived state remove hidden state, and the .py format makes notebooks reviewable, testable, and runnable as scripts or apps.
Key takeaways:
- Install with
pip install marimo; edit withmarimo edit, serve withmarimo run, convert withmarimo convert. - Cells form a DAG; changing a value reruns only its dependents, and each variable is defined in exactly one cell.
- UI element
.valueis a graph variable, so widgets drive updates without callbacks. - The notebook is a real
.pyfile: git-friendly, importable, runnable withpython notebook.py, and parameterizable viamo.cliargs. - Use
mo.cacheand persistent cache for expensive work,mo.stopto guard cells,mo.sqlfor DuckDB-backed queries, andpytestfor tests. - Export to WASM with
marimo export html-wasmfor browser-only deployment.
If your team has been burned by notebooks that work on one machine and break on another, Marimo's model is worth a serious trial on a real project.