Marimo: Reactive Python Notebooks
First look at Marimo — a reactive notebook runtime for Python that treats notebooks as pure Python files and reruns cells automatically on change.
What Marimo Is
Marimo is a reactive Python notebook — when you change a cell, every downstream cell that depends on it reruns automatically. No hidden state, no out-of-order execution surprises. The thing that makes it different from Jupyter isn’t the UI, it’s the execution model.
Each notebook is a valid .py file. You can run it as a script (python notebook.py), serve it as a web app (marimo run), or open it in the editor (marimo edit). No .ipynb JSON blobs, no metadata drift.
Setup
uv add marimo
marimo edit
uv add drops it into the project’s virtual environment without touching global state. marimo edit opens the browser-based editor at localhost:2718.
The Reactive Model
In Jupyter, cells are manually ordered and executed. You can run cell 5 before cell 3 and end up with stale state that doesn’t match what the code says. Marimo solves this with a dependency graph: if cell B uses a variable defined in cell A, Marimo knows that and reruns B whenever A changes.
Cell A: x = 10 ← change this
Cell B: y = x * 2 ← automatically reruns
Cell C: print(y) ← automatically reruns
This also means: no mutable global state between cells. Each cell is a function. If you try to assign the same variable in two cells, Marimo flags it as a conflict.
UI Elements Built In
Marimo has first-class UI widgets — sliders, dropdowns, text inputs — that integrate with the reactive graph. A slider value changing is the same as a cell output changing: everything downstream reruns.
slider = mo.ui.slider(1, 100, value=10)
slider
result = slider.value ** 2
result
Move the slider → result updates instantly. No callbacks, no event handlers.
Notebook as App
marimo run notebook.py serves the notebook as a web app — UI elements are visible, code cells are hidden. This is a clean path from exploratory analysis to shareable tool without rewriting anything.
What’s Different From Jupyter
| Jupyter | Marimo | |
|---|---|---|
| File format | .ipynb (JSON) | .py (pure Python) |
| Execution | Manual, ordered | Reactive, automatic |
| Hidden state | Common | Impossible by design |
| Git diffs | Messy | Clean |
| Run as script | Needs nbconvert | Native |
Wiring Up a Local LLM Chat
mo.ui.chat takes a callback that receives the message history and returns a string. Plugging Ollama in is a few cells:
Cell 1 — imports:
import os
from dotenv import load_dotenv
Cell 2 — load env, returns True on success:
load_dotenv()
Cell 3 — marimo:
import marimo as mo
Cell 4 — http client:
import requests
Cell 5 — endpoint from env, with fallback:
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
Cell 6 — the respond callback:
def ollama_respond(messages):
last = messages[-1].content
res = requests.post(f"{OLLAMA_URL}/api/generate",
json={
"model": "llama3.1:8b",
"prompt": last,
"stream": False
})
return res.json()["response"]
Cell 7 — wire callback into chat widget:
chat = mo.ui.chat(ollama_respond)
Cell 8 — render:
chat
The chat widget handles the UI entirely — input box, message history, scroll. Your callback only needs to return a string. This pattern — reactive cells + a local model — makes for a clean local AI playground. No server setup beyond Ollama, no API keys, and the notebook stays a plain .py file.
First Impressions
The execution model is the right one — Jupyter’s hidden state has caused real bugs in data pipelines. Marimo removes that class of problem by construction. The trade-off is that you have to think more carefully about variable scope upfront, which is actually good discipline.
The .py file format is a genuine win for version control. Diffing a notebook change is actually readable.