Marimo Tutorial: Reactive and Reproducible Python Notebooks

# Marimo: Notebook Python yang Reaktif dan Reproducible Marimo adalah notebook Python yang menyimpan isinya sebagai berkas `.py` biasa dan menjalankan sel secara reaktif, mirip cara spreadsheet mengh...

By Ruby Abdullah · · tutorial
MarimoNotebookReactiveData ScienceReproducibilityPython

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 with marimo edit, serve with marimo run, convert with marimo convert.
  • Cells form a DAG; changing a value reruns only its dependents, and each variable is defined in exactly one cell.
  • UI element .value is a graph variable, so widgets drive updates without callbacks.
  • The notebook is a real .py file: git-friendly, importable, runnable with python notebook.py, and parameterizable via mo.cliargs.
  • Use mo.cache and persistent cache for expensive work, mo.stop to guard cells, mo.sql for DuckDB-backed queries, and pytest for tests.
  • Export to WASM with marimo export html-wasm for 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.

Related Articles

Kedro Tutorial: Reproducible and Maintainable Data Science Pipelines

Kedro: Pipeline Data Science yang Reproducible dan Mudah Dirawat Sebagian besar proyek data science dimulai dari satu no...

DuckDB: In-Process Analytical Database for Data Science

DuckDB: Database Analitik In-Process untuk Data Science DuckDB adalah database analitik in-process yang dirancang khusus...

Polars Tutorial: Ultra-Fast DataFrame Library for Data Science

Polars - Tutorial Lengkap Library DataFrame Ultra-Cepat Daftar Isi Pendahuluan Prasyarat Dasar-Dasar Polars [Evaluasi La...

Feature Engineering Masterclass Tutorial: Feature Techniques for ML

Tutorial 14: Masterclass Rekayasa Fitur (Feature Engineering) Daftar Isi Pendahuluan Prasyarat Mengapa Rekayasa Fitur Pe...