Exploratory Data Analysis in Python with marimo
A general-purpose baseline for now — the plan is to trim this down to the workflow I actually use, and drop the rest.
Why marimo over Jupyter
marimo notebooks are stored as plain .py files, not JSON (.ipynb), which means clean diffs, working git blame, and a file you can import from or run as a script. The bigger difference is reactivity: marimo builds a dependency graph from variable names across cells, so re-running one cell automatically re-runs every cell downstream of it. Jupyter has no such graph — it’s entirely possible to have stale output on screen from a variable that was reassigned three cells up and never re-run, which is a routine source of “why is this wrong” during EDA specifically, where cells get re-ordered and re-run constantly.
The trade-off: marimo forbids reusing the same variable name across top-level cells (each name must have exactly one defining cell), since that’s what makes the dependency graph unambiguous. This is a minor adjustment coming from Jupyter’s “just rerun cells in whatever order” habit, but it’s what keeps notebook state consistent with what’s on screen.
Running it with uvx — no install, no venv
uvx runs a PyPI tool ephemerally in an isolated environment, so there’s nothing to install into a project and nothing to clean up:
uvx marimo editThis opens a fresh, blank notebook in the browser. To open (or create) a specific file:
uvx marimo edit notebook.pyPull in extra packages for the session without touching a project’s own dependencies — useful for a one-off exploration of a CSV or parquet file that doesn’t warrant a pyproject.toml:
uvx --with pandas --with altair marimo edit notebook.pymarimo also tracks its own per-notebook dependencies inline (PEP 723-style, similar to uv run --script), so a notebook created this way stays reproducible — anyone else can uvx marimo edit notebook.py and get the same packages, without needing to know what was passed on the command line originally.
| Command | Description |
|---|---|
uvx marimo edit | Open a new blank notebook. |
uvx marimo edit notebook.py | Open/create a specific notebook. |
uvx marimo edit --sandbox notebook.py | Force an isolated env even if a .venv is active — pulls in the notebook’s own inline deps. |
uvx marimo run notebook.py | Run as a read-only app (no code editing) — for sharing a result. |
uvx marimo run notebook.py --headless | Serve without auto-opening a browser tab (e.g. on a remote box). |
uvx marimo convert old.ipynb -o new.py | Convert an existing Jupyter notebook into a marimo .py notebook. |
Reactive notebook basics
Each cell is a function; marimo infers its inputs and outputs from the variable names read and assigned inside it. The one rule that trips people up coming from Jupyter: don’t reuse a variable name across cells — reassigning df in a second cell isn’t a “later value,” it’s a naming conflict marimo will flag, because two cells can’t both claim to define the same name in the dependency graph. Use df_clean, df_filtered, etc. instead.
import marimo as mo
import polars as pl
df = pl.read_parquet("data.parquet")
df.head()# a later cell can just read `df` — marimo wires the dependency automatically
summary = df.describe()
summaryBecause of that graph, a UI element’s value is just a variable other cells can depend on — this is what makes marimo notebooks good for interactive EDA specifically: change a slider, every cell that reads it re-runs.
threshold = mo.ui.slider(0, 100, value=50, label="Min amount")
thresholddf.filter(pl.col("amount") > threshold.value)The core EDA toolkit inside a notebook
marimo doesn’t replace the actual data libraries — it’s the shell around them. A typical stack:
| Library | Role |
|---|---|
polars or pandas | Load and manipulate the data. See Polars: Reading & Exploring Data for the DataFrame-level cheat sheet. |
altair | Declarative charting — plays natively with mo.ui elements for interactive filtering. |
marimo.ui | Sliders, dropdowns, tables, file uploaders — reactive inputs wired straight into cells. |
great_tables / mo.ui.table | Nicer rendering of DataFrames than a bare print. |
A minimal first-look pattern, in marimo cells:
import marimo as mo
import polars as pl
import altair as alt
df = pl.read_parquet("data.parquet")
mo.ui.table(df.head(20))mo.ui.table(df.describe())chart = alt.Chart(df.to_pandas()).mark_bar().encode(
x=alt.X("category:N"),
y=alt.Y("count():Q"),
)
mo.ui.altair_chart(chart)mo.ui.altair_chart wraps a chart to expose brushing/selection back into Python — selecting a range of bars gives you the selected rows as a DataFrame in the next cell, which is the interactive-EDA payoff reactivity is for: no manual re-filtering, no rerun-in-the-wrong-order risk.
Layout and markdown
mo.md() renders markdown (including inline LaTeX and embedded UI elements) as cell output, and mo.hstack / mo.vstack arrange multiple outputs side by side — handy for putting a chart next to its summary stats during EDA instead of stacking everything vertically:
mo.hstack([mo.ui.table(df.describe()), chart])mo.md(f"Rows above threshold: **{df.filter(pl.col('amount') > threshold.value).height}**")Exporting
| Command | Description |
|---|---|
uvx marimo export html notebook.py -o out.html | Static HTML snapshot of the current output — good for sharing a finished EDA pass. |
uvx marimo export script notebook.py -o out.py | Flatten to a plain, linearly-ordered .py script (no marimo runtime needed to read it). |
uvx marimo export ipynb notebook.py -o out.ipynb | Convert back to Jupyter format, e.g. to hand off to a Jupyter-only collaborator. |