DEV Community

Cover image for Marimo: The Reactive Python Notebook for Reproducible Data Work
Gowtham Potureddi
Gowtham Potureddi

Posted on

Marimo: The Reactive Python Notebook for Reproducible Data Work

marimo reactive python notebook is an open-source Python notebook that fixes the one thing every data person has silently tolerated for a decade: a notebook whose displayed output does not match the code you can see. In a classic notebook you can run cell 5, delete cell 3, edit cell 1, and never rerun the cells in between — so the variables in memory are the residue of a run history nobody recorded. Marimo makes that class of bug impossible by treating your notebook as a dataflow graph: it reads which variables each cell defines and references, wires the cells into a directed acyclic graph, and when you change one cell it automatically reruns every cell that depends on it. There is no stale state to reason about, because there is no state that the code on screen cannot reproduce.

The second idea is just as consequential and follows from the first: a Marimo notebook is stored as a plain .py file, not a JSON blob. That means it diffs cleanly in git, imports like any module, runs as a script with python notebook.py, and serves as an interactive web app with marimo run — the same file, three faces. This guide walks the four things an interviewer will actually probe — the reactive dataflow DAG, the pure-Python reproducibility model, UI elements bound to variables, and SQL cells backed by DuckDB — and pairs each with a Solution-Tail interview answer: code, a step-by-step trace, an output table, then a concept-by-concept breakdown of why it works.

PipeCode blog header for Marimo — bold white headline 'Marimo: the reactive notebook' with subtitle 'Dataflow DAG · Pure Python · Reproducible' and a stylised cell-graph scene on a dark gradient with purple, green, orange, and blue accents and a small pipecode.ai attribution.

When you want hands-on reps immediately after reading, drill the data-analysis practice library →, rehearse frame-shaping on the dataframe-basics practice set →, and design the end-to-end flow on the pipelines practice set →.


On this page


1. Why Marimo changes the Python notebook in 2026

Marimo is reactive, not top-to-bottom — that one fact removes hidden state and makes a notebook reproducible

The one-sentence invariant: Marimo derives the execution order from your code's dependencies, not from the order you happened to click cells, so the notebook you see is always the notebook that ran. Everything that makes Marimo attractive to a data team follows from that. There is no "run all from the top and pray" ritual, no execution_count that reads [47] next to [2], no variable that survives from a cell you already deleted. The graph is the source of truth, and the graph is recomputed from the code every time.

The problem Marimo is fixing — hidden state.

  • Out-of-order execution. In a classic notebook, cells can run in any order and the interpreter keeps whatever state that produced. The displayed outputs can reflect code that no longer exists on screen.
  • Stale variables. Delete the cell that defined df, and df lingers in memory. Every downstream cell keeps "working" against a ghost until you restart the kernel and discover it was broken all along.
  • The reproducibility tax. Because none of this is safe, the community habit is "Restart & Run All" before trusting a result — a manual discipline that people forget exactly when it matters.

How Marimo removes it.

  • Static analysis builds a DAG. Marimo parses each cell to see which global names it defines and which it references, then draws an edge from definer to referencer. That graph is a DAG (cycles are rejected).
  • Change one cell, dependents rerun. Edit a cell and Marimo automatically reruns its downstream cells; delete a cell and Marimo removes its variables and invalidates the cells that used them. State on screen and state in memory can never drift.
  • Deterministic order. Execution follows a topological sort of the DAG, so two people (or CI) running the same notebook get the same order and the same result.

What interviewers listen for.

  • Do you say "Marimo is reactive — the DAG decides execution order" in the first sentence? — senior signal.
  • Do you frame the core win as "no hidden state, so the notebook is reproducible by construction"? — required framing.
  • Do you mention that a notebook is a pure .py file, so it diffs and imports? — the practical hook.
  • Do you know that a UI element becomes reactive just by reading its .value in another cell, with no callbacks? — the "you actually used it" tell.

Worked example — the bug Marimo makes impossible

Detailed explanation. The clearest way to feel the difference is to picture the one sequence that silently corrupts a Jupyter notebook and watch Marimo refuse it. You define x, define y = x + 1, then go back and edit x. In Jupyter, y is now stale until you remember to rerun it. In Marimo, editing x reruns y for you — there is no window in which the two disagree.

Question. Two cells: cell A holds x = 1, cell B holds y = x + 1 and displays y. You change cell A to x = 10. What does y show in Jupyter versus Marimo?

Input.

cell code
A x = 10 (edited from 1)
B y = x + 1

Code.

x = 10          # cell A

y = x + 1       # cell B (a separate cell)
y
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation. In a classic notebook, editing cell A does nothing to cell B until you manually rerun B; y keeps showing 2 while x is already 10. In Marimo, the moment cell A is edited, the runtime sees that cell B references x, marks B as a descendant of A, and reruns B — so y becomes 11 with no action from you. The displayed value can never lag the code because the runtime, not the human, owns the order.

Output.

environment value of y after editing x to 10 stale?
Classic notebook 2 (until you rerun B) yes
Marimo 11 (reran automatically) no

Rule of thumb. If editing one cell can leave another cell's output wrong until you remember to rerun it, you are carrying hidden state — Marimo removes the "until you remember" entirely.


2. The reactive dataflow DAG

Cells are functions, references are edges — the DAG is derived from your code, and one variable lives in exactly one cell

Marimo has one central idea you must be able to explain: the notebook is a graph whose nodes are cells and whose edges are variable dependencies, and Marimo builds it by static analysis, never by running your code speculatively. An interviewer who asks "how does Marimo know what to rerun?" wants this graph, the single-definition rule that keeps it a clean DAG, and the minimal-recompute behaviour that falls out of it.

How the graph is built.

  • Defs and refs. Each cell is analysed for the global names it defines (assignments, def, import, class) and the names it references. A cell that references df depends on whichever cell defines df.
  • Edges point from producer to consumer. If cell 1 defines df and cell 3 uses df, there is an edge 1 → 3. Cell 3 is a descendant of cell 1.
  • It must be acyclic. Two cells cannot mutually depend on each other's variables; Marimo rejects cycles because a cycle has no valid execution order.

The single-definition rule.

  • One variable, one cell. A given global name may be defined in exactly one cell. Redefining df in a second cell is an error, not a silent overwrite — this is what guarantees the graph is well-defined.
  • Underscore for locals. A name prefixed with _ (e.g. _tmp) is cell-local and invisible to the graph, so you can reuse throwaway names freely without touching the DAG.
  • Why the rule exists. If two cells could both define df, "which one wins" would depend on run order — exactly the hidden state Marimo abolishes. Forbidding it keeps the DAG unambiguous.

What the runtime does with the DAG.

  • Minimal recompute. When a cell changes, Marimo reruns that cell and only its transitive descendants — not the whole notebook. Unrelated branches are left alone.
  • Delete = invalidate. Deleting a cell removes its variables from memory and reruns the cells that referenced them, so nothing keeps working against a ghost variable.
  • Topological execution. On a fresh run, cells execute in dependency order regardless of their visual position on the page, which is why you can arrange cells however reads best.

Iconographic Marimo dataflow diagram — notebook cells parsed into defs and refs, a directed acyclic graph linking them, the single-definition rule, and a changed cell rerunning only its downstream dependents.

Worked example — one edit, only the dependents rerun

Detailed explanation. The payoff of the DAG is surgical recomputation. Build a tiny four-cell notebook where a raw frame feeds a filter, the filter feeds a chart, and a wholly unrelated cell computes a constant. Change the raw frame and watch exactly two cells rerun while the unrelated cell sits still.

Question. Given cells that define raw, clean (depends on raw), chart (depends on clean), and note (independent), which cells rerun when you edit raw?

Input.

cell defines references
1 raw —
2 clean raw
3 chart clean
4 note —

Code.

raw = load_orders()                      # cell 1

clean = raw[raw["amount"] > 0]           # cell 2

chart = clean.plot(x="day", y="amount")  # cell 3

note = "dashboard v2"                    # cell 4 (independent of the chain)
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation. Marimo reads the four cells and draws edges 1 → 2 (both touch raw), 2 → 3 (both touch clean), and leaves cell 4 with no edges. When you edit cell 1, the runtime walks the descendants of node 1: cell 2 reruns because it references raw, then cell 3 reruns because it references clean. Cell 4 never runs because note depends on nothing that changed.

Output.

edited cell cells that rerun cells left untouched
1 (raw) 2 (clean), 3 (chart) 4 (note)

Rule of thumb. Marimo reruns the transitive descendants of what you changed — nothing upstream, nothing on a sibling branch — so an expensive independent cell is never recomputed by an unrelated edit.

Marimo interview question on the dependency graph

Question. An interviewer shows you a notebook where cell 1 defines df, cell 2 also tries to define df = df.dropna(), and cell 3 reads df. They ask why Marimo rejects this and how you would rewrite it so the intent (drop nulls, then use the clean frame) works reactively. Show the fix.

Solution Using distinct variables to keep the DAG acyclic

Code.

df = load_orders()                    # cell 1 — the raw frame, defined once

df_clean = df.dropna()                # cell 2 — new name (single-definition rule satisfied)

result = df_clean.groupby("customer")["amount"].sum()   # cell 3 — consume the cleaned frame
result
Enter fullscreen mode Exit fullscreen mode

Step-by-step trace.

cell attempted defs valid under single-definition rule? edge added
1 df yes —
2 (original) df again no — redefinition error rejected
2 (fixed) df_clean (refs df) yes 1 → 2
3 result (refs df_clean) yes 2 → 3
  1. The original cell 2 redefines df, which Marimo flags as a multiple-definition error — two cells owning one name would make execution order ambiguous.
  2. Renaming the cleaned frame to df_clean gives each name a single owner, so the graph stays a valid DAG.
  3. Now editing cell 1 reruns cell 2 (recomputes df_clean) and then cell 3 (recomputes result) automatically.
  4. Because df and df_clean are distinct nodes, you can inspect both the raw and cleaned frames at any time without one clobbering the other.

Output:

step frame shape
cell 1 df 1000 rows (with nulls)
cell 2 df_clean 960 rows (nulls dropped)
cell 3 result one sum per customer

Why this works — concept by concept:

  • Single-definition rule — one global name per cell makes the DAG unambiguous; Marimo can always answer "which cell owns df?" with exactly one node.
  • Distinct names as nodes — turning the mutation into a new variable df_clean adds a node and an edge instead of a hidden overwrite, so the transformation becomes a visible, rerunnable step.
  • Acyclic guarantee — because no cell redefines an upstream name, the graph has a topological order and every edit has a well-defined set of cells to rerun.
  • Reactive propagation — a change to df flows deterministically to df_clean and then result, with the runtime, not the analyst, choosing the order.
  • Cost — building the graph is O(cells × names) of cheap static parsing; a rerun touches only O(descendants), not the whole notebook.

Analysis
Topic — data-analysis
Exploratory data-analysis notebook problems

Practice →

DataFrames Topic — dataframe-basics DataFrame define-and-transform problems

Practice →


3. Pure-Python notebooks & reproducibility

The notebook is a .py file, so it diffs, imports, and runs three ways — reproducibility is a property of the format, not a discipline

The feature that sells Marimo to an engineer is that a notebook is stored as an ordinary Python module, not a JSON document. A .ipynb file interleaves source, base64 outputs, and execution metadata into JSON that produces unreadable diffs and merge conflicts; a Marimo file is code you can read, review, and run without a kernel. Reproducibility stops being a habit you enforce and becomes a property of the artifact.

What the file actually is.

  • A valid module. The notebook is a .py file defining an app = marimo.App() with each cell as a small decorated function that returns its defined names. It executes as plain Python.
  • Clean git diffs. Because it is code, a one-line change is a one-line diff — no serialized output blobs, no execution_count churn, no outputs: [] noise. Code review works.
  • No hidden outputs on disk. Cell outputs are not baked into the file, so you cannot accidentally commit a stale chart or a leaked credential printed three runs ago.

Deterministic execution, guaranteed.

  • Order from the DAG, not the page. On any run — yours, a colleague's, CI's — cells execute in topological order of the dependency graph, so the result does not depend on click history.
  • No out-of-order bugs. The one class of notebook bug that "works on my machine" comes from run order; Marimo eliminates it because run order is derived, not remembered.
  • Reproducible by construction. Given the same inputs and the same file, the same outputs follow — which is exactly what a data pipeline or a graded assignment needs.

Three run modes from one file.

  • marimo edit notebook.py opens the reactive editor for development.
  • marimo run notebook.py serves it as a read-only interactive web app — the code is hidden and only the UI and outputs show, so a notebook becomes a dashboard with no rewrite.
  • python notebook.py runs it as a script (great for a cron job or an Airflow task); because it is importable, you can also from notebook import result and unit-test cells with pytest.

Iconographic Marimo reproducibility diagram — a notebook stored as a pure .py module with a clean git diff, a deterministic execution order from the DAG, and three run modes: edit, app, and script.

Worked example — the same notebook as a script

Detailed explanation. The most convincing demonstration of the pure-Python format is running a notebook headless. Because the file is a real module with a __main__ guard, python notebook.py executes every cell in dependency order and any top-level output (or a mo.cli_output) is produced without ever opening a browser — the notebook and the batch job are the same code.

Question. You have analysis.py, authored in Marimo, that loads data and computes a summary. Show that it runs unchanged as a batch script and yields the same summary as the editor.

Input. A notebook whose last cell defines summary from an upstream orders frame.

Code.

import marimo    # file: analysis.py — authored in Marimo, stored as pure Python
app = marimo.App()

@app.cell
def _():
    import polars as pl
    orders = pl.read_parquet("orders.parquet")
    return (orders, pl)

@app.cell
def _(orders):
    summary = orders.group_by("customer").agg(total=("amount", "sum"))
    return (summary,)

if __name__ == "__main__":
    app.run()
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation. The file is legal Python: importing it or running it triggers app.run(), which executes the cells in DAG order — the orders cell first because the summary cell references orders. Nothing in the file depends on a browser or a saved kernel, so the batch run reproduces the editor run exactly. Because each cell returns its defined names, another module can from analysis import summary and assert on it in a test.

Output.

invocation executes produces
marimo edit analysis.py cells in DAG order (interactive) live summary in the editor
python analysis.py same cells, same order (headless) identical summary, no browser

Rule of thumb. If your notebook must also be a scheduled job or pass code review, choose a format that is already Python — reproducibility you get for free beats reproducibility you have to remember.

Marimo interview question on reproducibility

Question. A teammate reports that a .ipynb gives different numbers depending on who runs it, and blames "the data." You suspect out-of-order execution. Explain how moving to Marimo removes that failure mode, and what specifically guarantees a deterministic result.

Solution Using DAG-ordered execution instead of click order

Code.

rate = 1.08                      # cell A — defines rate

adjusted = base_amounts * rate   # cell B — uses rate to build adjusted

report = adjusted.describe()     # cell C — uses adjusted to build report
report
Enter fullscreen mode Exit fullscreen mode

Step-by-step trace.

environment how order is chosen result if cells were clicked A, C, B
Classic notebook human click order C sees old/undefined adjusted → wrong or error
Marimo topological sort of the DAG always A → B → C → correct report
  1. The three cells form a chain A → B → C because B references rate and C references adjusted.
  2. In a classic notebook, running them A, C, B produces a report built from a stale or missing adjusted, and the number depends on run history — not the data.
  3. Marimo ignores the order you interact with cells and executes in topological order every time, so A runs before B runs before C, deterministically.
  4. Because the file is pure Python with no stored outputs, a second person cloning the repo and running it gets byte-identical execution order.

Output:

run by execution order report
author (Marimo) A → B → C correct
teammate (Marimo) A → B → C identical

Why this works — concept by concept:

  • Topological order — deriving execution order from dependencies means the answer cannot depend on click history, which is the root cause of "works on my machine" notebooks.
  • Pure-Python format — no serialized outputs and no execution_count means the file that runs is exactly the file in git; there is nothing hidden to diverge.
  • Importable module — because cells return their names, the notebook can be imported and unit-tested, turning "trust me" into an assertion in CI.
  • Determinism as a default — reproducibility is guaranteed by the runtime rather than by a "Restart & Run All" ritual people forget.
  • Cost — the topological sort is O(cells + edges) once per run; the reproducibility it buys is unbounded in engineering time saved.

Pipelines
Topic — pipelines
Reproducible pipeline-ordering problems

Practice →

Analysis Topic — data-analysis End-to-end analysis reproducibility problems

Practice →


4. Interactive UI elements bound to variables

A widget is a Python object; reading its .value in another cell wires reactivity — no callbacks, no state juggling

Marimo's interactivity is the reactive DAG applied to human input. A UI element — mo.ui.slider, mo.ui.dropdown, mo.ui.text, mo.ui.table — is just a Python object with a live .value. The instant you read that .value in a different cell, that cell becomes a dependent of the widget, so moving the slider reruns exactly the cells that consume it. You never register a callback, never mutate shared state, never wire an event handler — the graph does it.

The binding model.

  • The element is a value. slider = mo.ui.slider(1, 100, value=10) creates an object; displaying it renders the control, and slider.value reads the current position as a plain Python number.
  • Define and display in one cell, read in another. The reactive contract is: the widget is defined and shown in its own cell, and other cells read slider.value. Reading it elsewhere is what draws the dependency edge.
  • Interaction = a cell edit. Dragging the slider is, to the runtime, equivalent to changing the cell that owns slider — so Marimo reruns the widget's descendants and nothing else.

Why there are no callbacks.

  • No on_change handlers. Traditional widget libraries make you attach a function that fires on change and mutate globals; that is imperative state management and it is where dashboards rot.
  • Reactivity replaces events. Because the dependent cell already declares what it needs (slider.value), Marimo knows what to rerun without you describing when. Declarative beats imperative.
  • A dashboard for free. Pair the widgets and their dependent charts, run marimo run, and the notebook is an interactive app — same file, no framework.

The control knobs.

  • mo.stop(predicate, output) short-circuits a cell and its descendants when a guard is true (e.g. no file uploaded yet), so an expensive graph does not run on empty input.
  • mo.ui.form wraps inputs so the graph reruns on submit rather than on every keystroke — the right tool for costly downstream work.
  • Compose with mo.ui.array / mo.ui.dictionary to build a list or map of elements and read them all through one .value.

Iconographic Marimo UI diagram — a slider and dropdown widget whose .value is read by a downstream cell, so moving the slider reruns only the dependent chart cell, with no callback wiring.

Worked example — a slider that filters a frame

Detailed explanation. The canonical interactive pattern is a slider that sets a threshold and a downstream cell that filters a dataframe by it. Define the slider in one cell, read slider.value in the filter cell, and the filtered frame (and any chart built from it) updates the moment you drag — with zero event wiring.

Question. Build a min_amount slider from 0 to 100 and a cell that shows only the orders rows whose amount is at least the slider value. What reruns when you drag it to 50?

Input.

order_id amount
1 20
2 60
3 90

Code.

import marimo as mo

min_amount = mo.ui.slider(0, 100, value=0, label="min amount")   # cell 1 — define + display
min_amount

filtered = orders[orders["amount"] >= min_amount.value]   # cell 2 — reads .value, depends on slider
filtered
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation. Cell 1 creates the slider object and displays the control; min_amount.value starts at 0. Cell 2 reads min_amount.value, so Marimo draws an edge from the slider cell to the filter cell. Dragging the slider to 50 is treated as a change to cell 1, so Marimo reruns cell 2 (and any chart cell below it) — recomputing filtered with the new threshold. No on_change, no global mutation: the dependency edge already said what to do.

Output.

slider value rows in filtered
0 1, 2, 3
50 2, 3
80 3

Rule of thumb. Define-and-display the widget in one cell, read .value in the cells that consume it — the moment you read the value elsewhere, the widget is wired reactively with no callback.

Marimo interview question on interactive reactivity

Question. You have an expensive model-fit cell that reads a mo.ui.dropdown of dataset names and a mo.ui.slider of epochs. The interviewer wants the fit to run only when the user explicitly submits — not on every twitch of the slider — while still being fully reactive. How do you build it?

Solution Using mo.ui.form to batch inputs before rerun

Code.

import marimo as mo

controls = mo.ui.dictionary({    # cell 1 — form reruns on SUBMIT, not per keystroke
    "dataset": mo.ui.dropdown(["a", "b", "c"], value="a"),
    "epochs":  mo.ui.slider(1, 50, value=10),
}).form()
controls

mo.stop(controls.value is None, mo.md("Set options and press **Submit**."))  # cell 2 — guard
params = controls.value                       # only set after submit
model = expensive_fit(params["dataset"], params["epochs"])
model
Enter fullscreen mode Exit fullscreen mode

Step-by-step trace.

user action controls.value cell 2 behaviour
dragging slider (no submit) None mo.stop halts — no fit
press Submit {"dataset": "b", "epochs": 30} guard passes → expensive_fit runs
drag again (no submit) still last submitted fit does not rerun
  1. Wrapping the inputs in .form() means their .value only updates on Submit, so mid-drag changes do not touch the graph.
  2. mo.stop(controls.value is None, ...) short-circuits cell 2 (and its descendants) until the first submit, so the expensive fit never runs on empty input.
  3. After submit, controls.value is a dict of the chosen options; cell 2 reads it and runs expensive_fit exactly once.
  4. Because the form is still a normal reactive object, a later submit reruns only cell 2 downstream — the reactivity is intact, just batched.

Output:

phase fit runs? why
before first submit no mo.stop guard
on submit yes (once) form value materialized
twiddling after submit no value unchanged until next submit

Why this works — concept by concept:

  • Widget as value — every input is a Python object whose .value participates in the DAG, so no event handlers are needed to connect input to computation.
  • Form batching — .form() defers the value update to submit, converting "rerun on every keystroke" into "rerun on intent," which is what expensive cells need.
  • mo.stop guard — short-circuiting the cell and its descendants keeps a costly graph dormant until inputs are valid, the reactive analogue of an early return.
  • Declarative reactivity — the dependent cell states what it reads, so Marimo reruns the right cells without you specifying when, eliminating callback spaghetti.
  • Cost — the fit is O(fit) and now runs only on submit; the reactive bookkeeping is O(descendants of the form), independent of how much the user fiddles.

DataFrames
Topic — dataframe-basics
Interactive filter-and-aggregate problems

Practice →

Analysis Topic — data-analysis Parameterized dashboard analysis problems

Practice →


5. SQL cells & DuckDB

mo.sql runs SQL over your Python dataframes with embedded DuckDB and hands back a dataframe — SQL and Python share one namespace

Marimo lets you write SQL as a first-class cell, and the engine underneath is embedded DuckDB. The magic is that a SQL cell can reference your in-memory Python dataframes by their variable name — no load step, no connection string — and it returns the query result as a dataframe that is itself a reactive variable. SQL feeds Python, which feeds more SQL, all inside the same DAG.

How a SQL cell works.

  • mo.sql(...) is the primitive. In the editor a SQL cell is authored as SQL, but it compiles to mo.sql("SELECT ..."); it executes the query on DuckDB and returns a dataframe.
  • Dataframes are tables. A Python dataframe named orders is queryable as FROM orders directly — DuckDB reads the pandas/polars frame in place, so there is no import or copy into a database.
  • The result is a reactive variable. Name the output (e.g. big_orders) and it becomes a node in the DAG; downstream Python or SQL cells that reference it rerun when the query changes.

Parameterizing and mixing.

  • f-string interpolation. Because the SQL is a Python string, you interpolate values with {...} — WHERE amount > {min_amount.value} wires a UI slider straight into a query, and moving the slider reruns the SQL cell.
  • SQL ⇄ Python round-trips. Clean in pandas, aggregate in SQL, chart in Python — each step is a cell, each output is a frame, and the DAG keeps them in sync.
  • One embedded engine. DuckDB runs in-process, so there is no server to provision; it is columnar and vectorized, so group-bys over millions of rows are fast on a laptop.

What interviewers probe.

  • Reactivity of results — the query output is a normal reactive frame, so a change upstream reruns the SQL and everything after it.
  • No hidden connection state — because DuckDB is embedded and the frames are in memory, there is no external database whose state could drift from the notebook.
  • When SQL beats pandas — set-based joins and aggregations read more clearly as SQL; row-wise Python logic stays in Python. Use each where it is strongest.

Iconographic Marimo SQL diagram — a mo.sql cell querying a Python dataframe by name through an embedded DuckDB engine and returning a new dataframe that a downstream Python cell consumes.

Worked example — SQL over a Python dataframe

Detailed explanation. The clearest demonstration is a SQL cell that aggregates an in-memory dataframe and returns a result you keep working with in Python. No CREATE TABLE, no INSERT, no connection — you name the frame in the FROM clause and DuckDB reads it directly.

Question. Given a Python dataframe orders(customer, amount), write a SQL cell that returns each customer's total spend, and show that the result is a dataframe you can use downstream.

Input.

customer amount
ada 40
linus 15
ada 60

Code.

import marimo as mo

totals = mo.sql(          # SQL cell — returns a dataframe named `totals`
    """
    SELECT customer, SUM(amount) AS total
    FROM orders
    GROUP BY customer
    ORDER BY total DESC
    """
)

top_customer = totals.iloc[0]["customer"]   # downstream Python cell consumes it reactively
top_customer
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation. mo.sql(...) hands the query to the embedded DuckDB engine, which resolves FROM orders against the in-memory Python frame — no load step. The engine groups by customer, sums amount, and returns the result as a dataframe bound to totals. Because totals is a normal reactive variable, the downstream cell reading totals.iloc[0] becomes its dependent, so if orders changes upstream, both the SQL cell and top_customer rerun.

Output.

customer total
ada 100
linus 15

Rule of thumb. If a step is a join or a group-by, reach for a mo.sql cell over your dataframe — DuckDB reads the frame in place and the result is just another reactive frame.

Marimo interview question on parameterized SQL

Question. You want a mo.ui.slider to set a minimum spend, and a SQL cell that returns only customers above that threshold, updating live as the slider moves. Show how the slider value reaches the SQL and why the query reruns.

Solution Using f-string interpolation from a UI element into SQL

Code.

import marimo as mo

min_spend = mo.ui.slider(0, 100, value=0, label="min spend")   # cell 1 — threshold slider
min_spend

big_spenders = mo.sql(          # cell 2 — SQL interpolates the slider value
    f"""
    SELECT customer, SUM(amount) AS total
    FROM orders
    GROUP BY customer
    HAVING SUM(amount) >= {min_spend.value}
    ORDER BY total DESC
    """
)
big_spenders
Enter fullscreen mode Exit fullscreen mode

Step-by-step trace.

slider value interpolated HAVING rows returned
0 >= 0 ada (100), linus (15)
50 >= 50 ada (100)
100 >= 100 ada (100)
  1. Cell 2 reads min_spend.value inside the f-string, so Marimo draws an edge from the slider cell to the SQL cell.
  2. Dragging the slider is treated as a change to cell 1, so Marimo reruns cell 2 — DuckDB re-executes the query with the new HAVING bound.
  3. DuckDB resolves FROM orders against the in-memory frame each run; no reload or connection is involved.
  4. The result big_spenders is a reactive frame, so any chart cell below it reruns too — the whole chain from slider to SQL to chart stays in sync.

Output:

slider big_spenders
0 ada, linus
50 ada

Why this works — concept by concept:

  • Embedded DuckDB — an in-process columnar engine queries Python frames by name, so there is no server, no connection state, and no copy into a database.
  • f-string parameterization — because SQL is a Python string, a UI element's .value interpolates directly, wiring a slider into a query through the same DAG that governs everything else.
  • Result is a reactive frame — naming the query output makes it a node, so SQL can feed Python which can feed more SQL, all kept consistent by the runtime.
  • Live re-execution — moving the slider reruns only the SQL cell and its descendants, giving an interactive query with no callback or refresh button.
  • Cost — each rerun is O(query) on DuckDB's vectorized engine over the in-memory frame; the reactive overhead is O(descendants of the slider), not the whole notebook.

Analysis
Topic — data-analysis
SQL-over-dataframe analysis problems

Practice →

Pipelines Topic — pipelines SQL-and-Python pipeline problems

Practice →


Cheat sheet — Marimo recipes

Minimal reactive cell pair.

x = 21          # cell 1
y = x * 2       # cell 2 — reruns automatically when x changes
y
Enter fullscreen mode Exit fullscreen mode

UI element bound to a variable.

import marimo as mo
n = mo.ui.slider(1, 100, value=10)   # define + display in one cell
n
squared = n.value ** 2               # in another cell: reading .value wires reactivity
Enter fullscreen mode Exit fullscreen mode

SQL cell over a dataframe (embedded DuckDB).

result = mo.sql("SELECT customer, SUM(amount) AS total FROM orders GROUP BY customer")
Enter fullscreen mode Exit fullscreen mode

Guard an expensive cell.

mo.stop(uploaded.value is None, mo.md("Upload a file to continue."))
model = expensive_fit(uploaded.value)
Enter fullscreen mode Exit fullscreen mode

Run three ways from one file.

marimo edit notebook.py     # reactive editor
marimo run  notebook.py     # read-only interactive app (code hidden)
python      notebook.py     # headless script (cron / Airflow)
Enter fullscreen mode Exit fullscreen mode

Sandbox with inline dependencies (reproducible env).

marimo edit --sandbox notebook.py   # deps pinned in the file via uv (PEP 723)
Enter fullscreen mode Exit fullscreen mode

Choosing where logic lives.

Situation Put it in
Join / group-by / filter over a frame a mo.sql cell (DuckDB)
Row-wise Python / model fit a Python cell
A value a human should tune a mo.ui element read via .value
Reuse across notebooks / tests a plain function imported into a cell

Frequently asked questions

What is Marimo (the reactive Python notebook)?

Marimo is an open-source Python notebook that runs reactively: it builds a dependency graph from your code and, when you change a cell, automatically reruns every cell that depends on it. There is no hidden state, because the runtime — not your click history — decides execution order. Notebooks are stored as pure .py files, so they diff in git, import as modules, and run as scripts or apps.

How is Marimo different from Jupyter?

Jupyter executes cells in whatever order you click them and stores the notebook as JSON with baked-in outputs, which invites out-of-order execution bugs, stale variables, and unreadable diffs. Marimo derives execution order from a dataflow DAG, forbids defining the same variable in two cells, and saves the notebook as a plain Python file. The practical effect is that a Marimo notebook is reproducible by construction, whereas a Jupyter notebook is reproducible only if you remember to "Restart & Run All."

How does Marimo know which cells to rerun?

Marimo statically analyzes each cell to see which global variables it defines and which it references, then draws an edge from the defining cell to the referencing cell. When a cell changes, Marimo reruns that cell and its transitive descendants in the graph — nothing upstream and nothing on an unrelated branch. Deleting a cell removes its variables and invalidates the cells that used them, so no cell keeps running against a ghost value.

Why are Marimo notebooks stored as .py files?

Because a plain Python file is diffable, reviewable, importable, and executable without a kernel. A .ipynb is JSON that mixes source, outputs, and execution counts, producing noisy diffs and merge conflicts and letting stale outputs live on disk. A Marimo .py file contains only code, so one edit is one line of diff, and the same file runs as an interactive app (marimo run) or a headless script (python notebook.py).

How do Marimo UI elements work without callbacks?

A UI element such as mo.ui.slider is a Python object with a live .value. You define and display it in one cell, and any other cell that reads its .value automatically becomes a dependent in the DAG. Interacting with the widget is treated as a change to its cell, so Marimo reruns only the dependent cells — there is no on_change handler and no global mutation, because the dependency edge already declares what to recompute.

Can Marimo run SQL?

Yes — Marimo has first-class SQL cells backed by an embedded DuckDB engine, invoked through mo.sql(...). A SQL cell can query your in-memory Python dataframes by variable name (no load step) and returns the result as a dataframe that is itself a reactive variable. You can interpolate Python values, including a UI element's .value, straight into the query with an f-string, so a slider can drive a live SQL result.

Practice on PipeCode

Pipecode.ai is Leetcode for Data Engineering — every Marimo idea above, from the reactive dataflow DAG and the single-definition rule to UI elements bound by `.value` and SQL-over-dataframe with DuckDB, maps to a hands-on practice room where you build the analysis against real graded inputs. PipeCode pairs each reading with 450+ DE-focused problems and a real-time scoring engine, so your answer to "how would you make this notebook reproducible?" holds up under a senior interviewer's depth probes.

Practice data-analysis problems now →
Pipeline-design drills →

Top comments (0)