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.
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
- Why Marimo changes the Python notebook in 2026
- The reactive dataflow DAG
- Pure-Python notebooks & reproducibility
- Interactive UI elements bound to variables
- SQL cells & DuckDB
- Cheat sheet — Marimo recipes
- Frequently asked questions
- Practice on PipeCode
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, anddflingers 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
.pyfile, so it diffs and imports? — the practical hook. - Do you know that a UI element becomes reactive just by reading its
.valuein 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
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 referencesdfdepends on whichever cell definesdf. -
Edges point from producer to consumer. If cell 1 defines
dfand cell 3 usesdf, 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
dfin 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.
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)
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
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 |
- The original cell 2 redefines
df, which Marimo flags as a multiple-definition error — two cells owning one name would make execution order ambiguous. - Renaming the cleaned frame to
df_cleangives each name a single owner, so the graph stays a valid DAG. - Now editing cell 1 reruns cell 2 (recomputes
df_clean) and then cell 3 (recomputesresult) automatically. - Because
dfanddf_cleanare 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_cleanadds 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
dfflows deterministically todf_cleanand thenresult, 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
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
.pyfile defining anapp = 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_countchurn, nooutputs: []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.pyopens the reactive editor for development. -
marimo run notebook.pyserves 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.pyruns it as a script (great for a cron job or an Airflow task); because it is importable, you can alsofrom notebook import resultand unit-test cells with pytest.
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()
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
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
|
- The three cells form a chain A → B → C because B references
rateand C referencesadjusted. - In a classic notebook, running them A, C, B produces a
reportbuilt from a stale or missingadjusted, and the number depends on run history — not the data. - Marimo ignores the order you interact with cells and executes in topological order every time, so A runs before B runs before C, deterministically.
- 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_countmeans 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
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, andslider.valuereads 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_changehandlers. 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.formwraps 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.dictionaryto build a list or map of elements and read them all through one.value.
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
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
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 |
- Wrapping the inputs in
.form()means their.valueonly updates on Submit, so mid-drag changes do not touch the graph. -
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. - After submit,
controls.valueis a dict of the chosen options; cell 2 reads it and runsexpensive_fitexactly once. - 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
.valueparticipates 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
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 tomo.sql("SELECT ..."); it executes the query on DuckDB and returns a dataframe. -
Dataframes are tables. A Python dataframe named
ordersis queryable asFROM ordersdirectly — 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.
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
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
Step-by-step trace.
| slider value | interpolated HAVING
|
rows returned |
|---|---|---|
| 0 | >= 0 |
ada (100), linus (15) |
| 50 | >= 50 |
ada (100) |
| 100 | >= 100 |
ada (100) |
- Cell 2 reads
min_spend.valueinside the f-string, so Marimo draws an edge from the slider cell to the SQL cell. - Dragging the slider is treated as a change to cell 1, so Marimo reruns cell 2 — DuckDB re-executes the query with the new
HAVINGbound. - DuckDB resolves
FROM ordersagainst the in-memory frame each run; no reload or connection is involved. - The result
big_spendersis 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
.valueinterpolates 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
Cheat sheet — Marimo recipes
Minimal reactive cell pair.
x = 21 # cell 1
y = x * 2 # cell 2 — reruns automatically when x changes
y
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
SQL cell over a dataframe (embedded DuckDB).
result = mo.sql("SELECT customer, SUM(amount) AS total FROM orders GROUP BY customer")
Guard an expensive cell.
mo.stop(uploaded.value is None, mo.md("Upload a file to continue."))
model = expensive_fit(uploaded.value)
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)
Sandbox with inline dependencies (reproducible env).
marimo edit --sandbox notebook.py # deps pinned in the file via uv (PEP 723)
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)