TL;DR:

  • Marimo eliminates the “hidden state” problem that makes Jupyter notebooks unreproducible — cells run in dependency order, not the order you clicked them
  • Every Marimo notebook is simultaneously a valid Python script, an interactive web app, and a deployable report
  • The reactive execution model catches data science bugs (stale variables, out-of-order execution) at development time instead of production
  • Git diffs on Marimo notebooks are readable — no JSON cell output blobs polluting your version control
  • The migration path from Jupyter is straightforward for most notebooks, though cell-level side effects require some rethinking

Jupyter notebooks have been the default environment for Python data science since 2015. They’re also responsible for a particular class of reproducibility failure that anyone who’s handed off a notebook to a colleague knows well: “I ran all the cells but I got different results.” The problem is hidden state — Jupyter tracks a kernel session, and cells can be run in any order, leaving variables in states that don’t correspond to the cell code as written. Restart the kernel and run top-to-bottom? Sometimes it works, sometimes it doesn’t, and you don’t find out until someone else tries it.

Marimo’s core design decision is to eliminate that failure mode entirely, and the decision has downstream effects that change how you think about notebooks, scripts, and interactive tools as a Python developer.

The Reactive Model: How Marimo Works

In Marimo, every cell declares its outputs and references its inputs explicitly (not through shared mutable state). When a cell changes, Marimo automatically re-runs all downstream cells that depend on it. There are no “stale” cells showing outputs that no longer reflect the current code — the notebook state is always consistent.

This is the reactive model borrowed from spreadsheets, applied to Python:

# Cell 1: defines df
df = pd.read_csv("data.csv")

# Cell 2: depends on df — runs automatically when cell 1 changes
summary = df.describe()

# Cell 3: depends on summary — runs automatically when cell 2 changes
mo.ui.table(summary)

If you change the CSV path in Cell 1, Cells 2 and 3 re-run immediately. You can’t be in a state where Cell 3 is showing results from an old CSV while Cell 1 points to a new one. The notebook enforces consistency.

What this means in practice: You can share a Marimo notebook with a colleague and they will get the same results you got. There is no “run in the right order” instruction needed. This is a stronger guarantee than any Jupyter workflow based on “restart and run all” conventions.

Installation and Getting Started

pip install marimo
marimo tutorial intro   # interactive intro notebook
marimo edit my_analysis.py  # open or create a notebook

Marimo notebooks are .py files, not .ipynb JSON. Open a notebook in the Marimo editor at localhost:2718.

# A Marimo notebook is a plain Python file with a specific structure
import marimo

__generated_with = "0.9.1"
app = marimo.App(width="medium")

@app.cell
def load_data():
    import pandas as pd
    df = pd.read_csv("sales_data.csv")
    return df,

@app.cell
def compute_summary(df):
    summary = df.groupby("region")["revenue"].sum().reset_index()
    return summary,

@app.cell
def visualise(summary, mo):
    import altair as alt
    chart = alt.Chart(summary).mark_bar().encode(
        x="region:N",
        y="revenue:Q"
    )
    return mo.ui.altair_chart(chart),

if __name__ == "__main__":
    app.run()

Cells are Python functions decorated with @app.cell. Arguments are cell outputs that this cell depends on. Marimo infers the dependency graph from function signatures and automatically orders execution.

Interactive UI Components

Marimo includes a UI library (marimo.ui, aliased as mo.ui) with interactive widgets that integrate with the reactive model:

@app.cell
def controls(mo):
    date_range = mo.ui.date_range(
        label="Date range",
        start="2026-01-01",
        stop="2026-08-04"
    )
    min_revenue = mo.ui.slider(0, 100000, step=1000, value=10000, label="Min revenue")
    return date_range, min_revenue,

@app.cell
def filter_data(df, date_range, min_revenue):
    filtered = df[
        (df["date"] >= date_range.value[0]) &
        (df["date"] <= date_range.value[1]) &
        (df["revenue"] >= min_revenue.value)
    ]
    return filtered,

When the user adjusts the slider or changes the date range, the filter_data cell re-runs automatically. The downstream visualisation cells re-run after that. The entire notebook stays consistent with the current UI state.

This is the key insight that makes Marimo more powerful than Jupyter widgets: in Jupyter, you wire up callbacks manually and hope they stay consistent. In Marimo, the reactive model handles consistency automatically — any cell that depends on filtered updates when filtered changes.

The Python Script Parity Advantage

Because Marimo notebooks are Python files, they work exactly as Python scripts:

# Run as a non-interactive script
python my_analysis.py

# Run as a Marimo app (interactive UI)
marimo run my_analysis.py

# Edit interactively
marimo edit my_analysis.py

# Export to HTML (static report)
marimo export html my_analysis.py -o report.html

# Export to Jupyter (for colleagues still on Jupyter)
marimo export ipynb my_analysis.py -o analysis.ipynb

For CI/CD pipelines, this matters enormously. You can run a Marimo notebook in CI as a test, check its outputs, and fail the pipeline if results have changed. No Jupyter-to-Python conversion step, no nbconvert dependency, no special CI configuration for notebook execution.

# GitHub Actions step
- name: Run analysis notebook
  run: python src/analysis/monthly_report.py

For version control, Marimo files diff cleanly:

- @app.cell
- def filter_data(df, date_range):
+ @app.cell
+ def filter_data(df, date_range, min_revenue):
      filtered = df[
          (df["date"] >= date_range.value[0]) &
-         (df["date"] <= date_range.value[1])
+         (df["date"] <= date_range.value[1]) &
+         (df["revenue"] >= min_revenue.value)
      ]
      return filtered,

Compare this to a Jupyter diff, which includes JSON output blobs, cell execution counts, and metadata that makes review almost impossible in practice.

What Marimo Changes for Data Science Workflows

Exploratory Analysis

The reactive model actually speeds up exploration once you adapt to it. Instead of manually re-running cells after changing a parameter, you adjust a slider and watch everything downstream update immediately. Data filtering, parameter sweeping, and feature engineering feel more like using a spreadsheet formula than writing scripts.

The limitation: Marimo cells must be pure functions (no shared mutable state). If you’re used to modifying dataframes in-place across multiple cells, you’ll need to refactor. Each cell receives its inputs, transforms them, and returns outputs. In practice this produces cleaner, more modular analysis code — but the adjustment takes time.

LLM and AI Development

Marimo’s reactive model is useful for LLM prototyping workflows:

@app.cell
def config(mo):
    model = mo.ui.dropdown(
        ["claude-opus-4-7", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"],
        value="claude-sonnet-4-6",
        label="Model"
    )
    temperature = mo.ui.slider(0.0, 1.0, step=0.1, value=0.7, label="Temperature")
    return model, temperature,

@app.cell
async def run_prompt(model, temperature, mo):
    import anthropic
    client = anthropic.Anthropic()
    
    response = client.messages.create(
        model=model.value,
        max_tokens=1024,
        temperature=temperature.value,
        messages=[{"role": "user", "content": "Explain the reactive programming model in one paragraph."}]
    )
    return mo.md(response.content[0].text),

Changing the model or temperature triggers a re-run of the prompt cell automatically. This makes A/B testing prompts across models interactive rather than requiring manual re-execution.

Marimo also supports async cells, which is useful for API calls that would otherwise block the notebook UI.

Reporting and Dashboards

Marimo notebooks deployed with marimo run become interactive web apps that non-technical stakeholders can use:

@app.cell
def dashboard_header(mo):
    return mo.md("""
    # Monthly Revenue Dashboard
    Use the controls below to filter by region and date range.
    """),

The same file that you use for development becomes the production dashboard. No Streamlit wrapper, no separate export step. Updates to the analysis automatically flow through to the deployed app.

Migrating From Jupyter

Most Jupyter notebooks migrate to Marimo with moderate effort. The main friction points:

Global state and in-place mutation: Jupyter notebooks often build up analysis through mutation (e.g., df = df.dropna() followed by df = df.rename(...) in separate cells). These need to be restructured as transformation chains within single cells or explicitly passed between cells.

IPython magic commands: %matplotlib inline, %timeit, and other magic commands don’t work in Marimo. Marimo has native alternatives for most (inline plotting is default; timing uses Python’s timeit module or the mo.stop cell stopping feature).

Cell-level side effects: Cells that produce side effects (write files, update databases, send requests) need careful handling — they’ll re-run whenever upstream dependencies change, which may not be what you want. Use mo.stop(condition) to guard cells that should only run when explicitly triggered.

@app.cell
def export_results(filtered_df, run_export):
    # Only export when the button is clicked
    mo.stop(not run_export.value)
    filtered_df.to_csv("results.csv", index=False)
    return mo.md("✓ Exported to results.csv"),

Practical migration approach: Start with new analysis notebooks in Marimo rather than migrating existing Jupyter notebooks. For existing notebooks, use marimo convert notebook.ipynb > notebook.py as a starting point, then fix the cell structure issues that the converter flags.

Marimo vs Jupyter: When to Use Each

Use Marimo when:

  • You’re building analysis pipelines that need to be reproducible and shareable
  • You want to turn an analysis into a shareable interactive report or dashboard
  • You’re prototyping AI/LLM pipelines with parameter sweeps
  • Your notebooks will be version-controlled and reviewed in PRs
  • You’re building new workflows from scratch and can design with the reactive model in mind

Stick with Jupyter when:

  • You have significant investment in existing Jupyter infrastructure (nbconvert, nbclient, Papermill)
  • Your team is deeply familiar with Jupyter and the migration cost isn’t justified by the gain
  • You rely on Jupyter extensions that don’t have Marimo equivalents
  • You need JupyterHub or Binder for multi-user environments (Marimo cloud hosting is newer and less mature)

Marimo is not trying to win every use case — it’s solving a specific problem (hidden state, reproducibility, scriptability) that matters a lot for data science teams that care about production-quality notebooks. If your notebooks are exploratory scratch pads that never leave a single machine, Jupyter’s additional flexibility might suit you better. If your notebooks are part of a data product that others depend on, Marimo’s guarantees are worth the migration cost.

Getting Started in 15 Minutes

pip install marimo pandas altair
marimo tutorial dataframe  # official dataframe tutorial

The official tutorials at marimo.io are the fastest path to understanding the reactive model. The dataframe tutorial in particular shows the interactive filtering and transformation workflow that demonstrates why the reactive model matters for data exploration.

The hardest part of adopting Marimo isn’t the syntax — it’s developing the habit of thinking about cell dependencies explicitly rather than relying on shared state. Once that mental shift happens, notebooks that used to break mysteriously start behaving reliably, and the “restart and run all” ritual that Jupyter requires for confidence stops being necessary.