Mastering Marimo
Reactive, Reproducible Python Notebooks for Data and AI

Soren Vasholt

No part of this publication may be reproduced, distributed, or transmitted in any form or by any means, including photocopying, recording, or other electronic or mechanical methods, without the prior written permission of the publisher, except in the case of brief quotations embodied in critical reviews and certain other noncommercial uses permitted by copyright law.

Published by NobleTrex Press

© 2026 by N O B T R E X LLC. All rights reserved.

For permissions and other inquiries, write to:
P.O. Box 3132, Framingham, MA 01701, USA

This book is for educational and informational purposes only. The author and publisher make no guarantees about the accuracy or completeness of its contents and accept no liability for any loss or damage resulting from its use. Software tools such as large language models have been applied to assist in the writing and editing of this book. All product names, logos, and trademarks are the property of their respective owners. References to third-party products or names are for identification only and do not imply endorsement.

Chapter 1
Introduction

Marimo represents a significant shift in how Python notebooks can be written, executed, shared, and maintained. This book is about using marimo not simply as a new notebook interface, but as a disciplined computing environment for data analysis, scientific work, analytics engineering, and AI development. Its premise is straightforward: notebooks should be interactive without being fragile, expressive without sacrificing correctness, and convenient without becoming isolated from the rest of the Python ecosystem.

Traditional notebook workflows have been valuable because they combine code, results, and explanation in a single place. At the same time, experienced users know their limitations. Execution order can become difficult to reason about. Hidden state can accumulate silently. Version control is often noisy. Refactoring across notebooks and packages is awkward. As projects mature, teams frequently face a split between exploratory notebooks on one side and production code, scripts, dashboards, and applications on the other. Marimo addresses these problems by changing the underlying model rather than merely improving the interface.

The central idea explored throughout this book is that a notebook can be treated as a reactive Python program. In marimo, dependencies between cells are inferred from symbol usage, and changes propagate according to that dependency structure. This makes the notebook more deterministic, reduces the risk of stale or misleading outputs, and encourages a style of work in which correctness and interactivity reinforce one another. The result is a notebook system that is more compatible with the expectations of software engineering while remaining well suited to exploratory work.

A second defining property of marimo is that notebooks are stored as pure Python files. This has practical consequences that extend far beyond file format preference. Python-native storage means cleaner diffs, more reliable merges, easier code review, and direct interoperability with editors, linters, formatters, test frameworks, and packaging tools. It also changes how notebook code can be reused. A marimo notebook can participate in a larger codebase as a script, a module, or an application surface, which reduces the gap between prototyping and operational use.

This book is organized to reflect that broader view. It begins by establishing the mental model required to work effectively in a reactive notebook environment and by clarifying where marimo fits relative to Jupyter, scripts, and lightweight apps. From there, it examines execution semantics, state management, and the design of deterministic notebook code. It then turns to engineering practices made possible by pure-Python notebooks, including refactoring, testing, continuous integration, and modular reuse.

Later chapters address interactive interfaces, data exploration, and AI experimentation using marimo’s no-callback approach to UI. The book also covers SQL as a first-class part of notebook design, showing how Python and SQL can coexist within reactive analytical workflows. It concludes with guidance on sharing

notebooks as applications, choosing among notebook, script, and app modes, migrating from existing notebook systems, and structuring environments and repositories for team-scale work.

The intended audience is technically proficient Python users who want notebook workflows that remain reliable as complexity increases. Whether your focus is analytics, machine learning, internal tools, or research code that must withstand repeated revision, marimo offers a model worth understanding carefully. The chapters that follow aim to provide that understanding in a precise and practical way.

Chapter 2
The Marimo Mental Model

Most experienced Python users approach a new notebook tool by asking whether it is better than Jupyter, lighter than an app framework, or easier to operationalize than a script. This chapter argues that those comparisons are useful only after you adopt the right mental model: marimo is best understood as a dataflow-driven, pure-Python notebook system whose execution semantics and file format change what a notebook can be trusted to do.

2.1 Locating Marimo in the Python Tooling Landscape

You usually feel the fracture in a Python workflow long before you name it. Exploration starts in a notebook because you need fast feedback, visible intermediate results, and a place to try ideas without ceremony. Once the logic becomes valuable, you pull pieces into scripts or packages because unattended execution, review, and reuse demand a different shape. If colleagues need to interact with the result, you often add an app layer with its own runtime and interface model. That progression is common not because teams are careless, but because the tools optimize for different operational centers of gravity.

A minimal marimo file makes that tension visible because it already looks like software rather than a special document. You can edit it interactively, run it as a Python program, and serve it as an app surface without first converting it into a different artifact type.

import marimo 

app = marimo.App() 

with app.setup: 
    import marimo as mo 
    import pandas as pd 

@app.cell 
def _(pd): 
    df = pd.DataFrame( 
        {"x": [1, 2, 3], "y": [2, 4, 8]} 
    ) 
    return (df,) 

@app.cell 
def _(df, mo): 
    mo.md(f"Rows: {len(df)}") 
    return 

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

You can work with that file through the normal marimo surfaces:

marimo edit notebook.py 
python notebook.py 
marimo run notebook.py 
marimo check notebook.py

Those commands are more than convenience wrappers. They express the core positioning of marimo in the Python ecosystem: one artifact can participate in interactive authoring, ordinary Python execution, app-style delivery, and source-oriented validation. The important question is not whether marimo can imitate every other tool, but which operational gap it is designed to narrow.

The operational problem Conventional notebook practice often forces you to choose between two desirable properties that should not be opposed. On one side, you want interactive computation: inspect data frames, tweak parameters, compare outputs, and preserve the local context of an investigation. On the other side, you want software qualities: reviewable source files, predictable execution, composable code, and artifacts that fit normal repository workflows. The split is structural. Traditional notebook artifacts are optimized as notebook documents, while scripts

and modules are optimized as source code. Lightweight app frameworks solve a different problem again: they package interaction for consumers rather than authors.

marimo addresses that split by treating the notebook as a Python-native artifact with stronger execution expectations than the usual manually ordered notebook session. That is the right level of framing here. The exact execution semantics matter enormously, but the important placement decision is simpler: marimo is for work that needs notebook-style interactivity without accepting the usual trade that interactivity must live in a separate, less inspectable, less reusable artifact class.

Why the fragmentation persists Experienced teams often normalize a three-stage pattern:

Prototype in a notebook.

Extract stable logic into scripts or packages.

Build a separate app if someone else needs to operate it.

That pattern works, but it imposes costs that compound over time. Logic is duplicated during extraction. Exploratory assumptions stay trapped in author-only notebooks. User-facing parameter controls are rebuilt in another framework. Review happens on one representation while actual day-to-day work happens in another. Teams then create conventions to manage the mismatch: “restart and run all,” “do not trust saved outputs,” “copy this code into a module before production use,” or “ignore notebook diffs.” Those conventions are coping mechanisms for tool boundaries.

marimo does not eliminate the need for design discipline. You still need to decide what belongs in a reusable module, what should remain exploratory, and where side effects should occur. What changes is that the notebook no longer begins life as an isolated format that must later be translated into something more software-like.

Relative to Jupyter: a shift in assumptions The most immediate comparison class is Jupyter because both environments support cell-based interactive work. The difference worth naming is not a checklist of widgets, editors, or kernels. The deeper difference is the assumed execution model and the kind of artifact you are editing.

A Jupyter notebook is primarily a notebook document. Its file representation is a JSON structure containing cells, metadata, and related state. That representation

supports a rich notebook experience, but it also means the notebook sits somewhat apart from ordinary Python source workflows. Even when you enforce discipline, the user experience encourages a transcript-like mindset: code cells appear in an order, execution can be selective, and the session state can drift from visible code unless you actively reset it.

marimo changes the expectation. You should approach the notebook less as a manually curated execution diary and more as an inspectable computational artifact whose behavior is tied to code relationships rather than personal memory of what you ran. The detailed mechanics of dependency analysis and recomputation belong elsewhere, but the vocabulary matters now: marimo assumes stronger consistency between the notebook’s visible definitions and its execution state than many notebook users are accustomed to managing by hand.

That shift has practical consequences for how you evaluate the tool. If you judge marimo as only a prettier replacement for Jupyter, you will miss the design center. The point is not simply notebook ergonomics. The point is to reduce the gap between interactive analysis and software behavior.

Relative to scripts: Python-native without giving up interactivity Plain Python scripts occupy the opposite side of the usual trade-off. They fit the ecosystem cleanly. You can run them with the interpreter, import them into other code, track them in Git, lint them, and wire them into automation. Their weakness is not engineering quality but interactive affordance. A script does not naturally expose intermediate computation, parameter controls, or notebook-style inspection. To recover that experience, you often fall back to ad hoc print debugging, command-line flags, or repeated edits to constants.

marimo sits much closer to scripts than many notebook systems do because the underlying artifact is a .py file. That matters immediately. A marimo notebook can be executed with:

python my_marimo_notebook.py

It can also be checked as source:

marimo check my_marimo_notebook.py

Those capabilities change the decision boundary for exploratory engineering work. If your task combines iteration with the need to preserve a source-oriented artifact, marimo lets you stay inside Python-native tooling while retaining notebook affordances. That is especially attractive for analytical pipelines, model evaluation work, data-quality investigations, and internal research artifacts where the same file may be inspected by a human one day and run non-interactively the next.

You should still prefer an ordinary script or package when interactivity adds no value. Batch jobs, CLI-first utilities, long-running daemons, and library-only code do not become better merely because they can be expressed in notebook cells. marimo is compelling when inspection and adjustment are part of the operational reality, not

when they are absent.

Relative to app frameworks: interaction without immediate callback architecture The third comparison class is lightweight application frameworks, especially those used for internal tools, dashboards, model demos, and parameterized reports. These frameworks solve a real problem: they let you expose computation to users without building a full custom frontend. Their design center, however, is application delivery. You typically think first in terms of controls, reruns, callbacks, or app-state conventions.

marimo overlaps with that territory because it can serve notebooks as apps:

marimo run notebook.py

That overlap is significant for teams building analytical interfaces for colleagues rather than public consumer products. You can move from author-centric exploration to shareable interaction without immediately adopting a separate UI architecture or splitting code into an app-specific layer. For many internal tools, that is exactly enough abstraction.

Still, you should resist evaluating marimo as if it were a general substitute for every web app framework. If the center of difficulty is custom frontend behavior, routing, authentication-heavy application structure, multi-user server concerns, or elaborate browser-side state, a notebook-native system is not automatically the best foundation. marimo is strongest when the interface is a thin, computation-oriented layer over Python logic, not when the interface itself is the dominant engineering problem.

What marimo is really combining The useful mental shortcut is that marimo occupies the intersection of four needs:

interactive computation,

Python-native source representation,

reusable program structure,

lightweight delivery to other users.

Most tools in the surrounding landscape optimize one or two of these at the expense of the others. Jupyter strongly serves interactive computation but uses a notebook document model. Scripts strongly serve source representation and reuse but offer little built-in interactive structure. App frameworks serve delivery and interaction for consumers, often with a different execution model and programming style. marimo’s design goal is to let one artifact participate credibly in all four without pretending that the trade-offs disappear.

This is why marimo is especially attractive in mixed exploration-engineering workflows. Data scientists, ML engineers, research engineers, and platform-adjacent developers often do not work in clean phases where exploration ends before engineering begins. They move back and forth. A model evaluation notebook becomes a reviewed artifact. A data investigation becomes a recurring operational report. An internal analytical tool starts as personal code and then gains users. marimo fits those transitions because it does not force an early commitment to a document-only notebook or a user-only app.

Where marimo is especially compelling Several categories of work benefit directly from that positioning.

Iterative data analysis. You inspect data, transform it, compare candidate computations, and need visible outputs tied closely to code. At the same time, teammates may need to review the artifact or rerun it later. marimo works well when exploration and code hygiene must coexist.

AI and model experimentation. Prompt evaluation, feature exploration, benchmark comparison, and error analysis often involve repeated parameter changes and close inspection of intermediate results. The artifact may later become a reproducible experiment record or a lightweight interactive demo for collaborators.

Internal analytical tools. Many teams need interfaces that are more interactive than a script but far simpler than a custom web application. marimo can provide a practical middle ground when users mainly need to change inputs, inspect outputs, and trust that the underlying logic is still Python code rather than a separate frontend stack.

Reviewable research artifacts. When you want analysis to remain inspectable in a pull request, source-oriented storage matters. The notebook is not merely something you exported after the real work; it remains the work product.

Notebook-to-software continuums. Some artifacts genuinely need to play multiple roles over time: notebook for authoring, script for unattended execution, module for reuse, app for wider consumption. marimo is designed for that continuum more than for any single role in isolation.

Where marimo is not automatically the best fit A credible technical tool description must also make the boundaries explicit.

Large production services. If you are building a long-lived network service, a distributed backend, or a system whose core complexity is service composition, observability, fault isolation, and deployment topology, marimo is not the primary abstraction you need. You may still use it around the edges for analysis or operator-facing tools, but not as the backbone of the service.

Deeply custom web frontends. When your main challenge is bespoke interaction

design, rich browser behavior, or precise client-side control, a notebook-native UI surface will feel constraining. marimo can expose analytical interaction effectively, but it is not intended to replace full frontend engineering.

Distributed orchestration problems. If the central difficulty is scheduling large workflows, coordinating jobs across systems, or managing data movement and retries across infrastructure, your primary tool should address orchestration directly. marimo can sit at the analysis or control layer, but it should not be mistaken for an orchestration framework.

Pure library development. If the end product is a package with no meaningful interactive dimension, ordinary modules remain the cleaner default. marimo should add a concrete advantage, not conceptual novelty.

Common evaluation mistakes Three mistakes appear repeatedly when experienced Python users first encounter marimo.

Treating it as only a notebook facelift. That misses the reason the artifact is stored as Python and the reason execution semantics are treated more rigorously. If you import the habits of a manually managed notebook session unchanged, you will underuse the design.

Assuming every notebook should become an app. The fact that a notebook can be shared as an app does not mean app delivery is the right endpoint for every artifact. Many notebooks are for authors and close collaborators, not general users. Adding a user-facing surface too early can freeze exploratory code into a premature interface.

Ignoring role boundaries because one artifact can do many things. Flexibility is useful only when you still choose a primary role. A notebook that is simultaneously trying to be an exploratory scratchpad, a stable library API, a batch script, and a polished app will usually do all four poorly. The right question is not “What else can this file do?” but “Which role dominates, and what constraints follow from that?”

Version awareness without overfitting marimo continues to evolve actively, so product surfaces and ergonomics can change faster than long-stable parts of the Python standard library. The durable ideas are the ones that matter most here: reactive notebooking, Python-file storage, ordinary execution surfaces, and app-style sharing from the same artifact class. When you make operational decisions, anchor them in those stable design principles rather than in incidental UI details.

That framing gives you a clear boundary for where marimo belongs. Use it when interactive computation and software discipline need to coexist in the same Python-native artifact. Reach for other tools when your dominant problem is document-style notebooking without stronger execution guarantees, pure library construction without interactive needs, or full application engineering whose main complexity lies outside interactive computation.

2.2 Reading Notebooks as Reactive Dataflow Programs

The hardest habit to unlearn from traditional notebook work is the idea that a notebook is fundamentally a linear transcript. You scroll from top to bottom, cells appear in an order, and execution feels like a series of commands you issue manually. That model is serviceable when the notebook is small and you are the only person touching it. It becomes fragile when the notebook holds real analytical logic, expensive computation, or results that other people must trust. At that point, the central question is no longer “what did I run?” but “what does this result depend on?”

A marimo notebook answers that second question directly. The governing abstraction is not page order but dataflow. A cell defines global names. Other cells read those names. marimo statically analyzes each cell once, determines which global names it defines and reads, and constructs a directed acyclic graph from those relationships. Reactive execution follows that graph. If you change one definition, cells that depend on it become the relevant downstream work.

A compact example makes the shift concrete. The notebook below has three cells: one defines x, one derives y, and one renders the result.

import marimo 

app = marimo.App() 

with app.setup: 
    import marimo as mo 

@app.cell 
def _(): 
    x = 10 
    return (x,) 

@app.cell 
def _(x): 
    y = x + 1 
    return (y,) 

@app.cell 
def _(mo, y): 
    mo.md(f"$y = {y}$") 
    return 

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

If you edit the first cell and change x = 10 to x = 20, the conceptual effect is not “rerun some later cells if you remember.” The effect is “the definition of x changed, so anything that reads x, and anything that depends on those results, is affected.” That sounds simple, but it changes how you reason about correctness. You stop treating notebook execution as a private sequence of gestures and start treating it as a dependency-driven program.

Cells as producers and consumers of symbols The useful unit of reasoning is the symbol, not the screen position of a cell. When one cell returns x, it is publishing a value into the notebook’s global environment. When another cell accepts x as an argument, it is declaring that it consumes that published value. The notebook becomes a graph of producers and consumers.

This language is deliberately stricter than the casual notebook habit of saying “that cell comes after this one.” In a dataflow notebook, being lower on the page does not make a cell downstream. Reading a symbol makes it downstream. Two cells can be visually adjacent and entirely unrelated, or visually far apart and tightly coupled through shared names. Once you adopt that lens, notebook structure becomes easier to inspect and explain. A change propagates because of a dependency edge, not because of a human recollection that “the later analysis used that earlier variable.”

That distinction matters most when the notebook grows beyond toy size. In exploratory data analysis, the temptation is to keep adding cells as thought fragments: inspect a subset here, fit a model there, compute a metric elsewhere, and assume you understand the global state because you created it. The assumption breaks down quickly. A dataflow-oriented notebook asks you to make dependencies legible in the code itself.

Why execution order stops being the primary truth Experienced notebook users often protect themselves from state drift through ritual. Restart the kernel. Run all cells. Avoid executing out of order. Do not trust results unless the whole notebook was rerun cleanly. Those rules are sensible under a document-like execution model because state can diverge from the visible notebook. The notebook is one thing; the live session is another.

marimo narrows that gap by making execution depend on definitions and references rather than notebook page order. The visual order still matters for readability, narrative, and code organization, but it is not the ground truth of computation. Ground truth is the dependency graph derived from what cells define and what other cells read.

That changes everyday behavior in two ways. First, you no longer need to manage validity primarily through memory and manual sequencing. Second, when something changes unexpectedly, you have a principled explanation path: inspect which symbols changed and which cells depend on them. The notebook becomes more explainable because its behavior is tied to visible dependency structure rather than to an invisible history of manual runs.

Reactive execution as a semantic contract It is tempting to treat reactivity as a productivity feature, something nice to have because it saves keystrokes. That understates its role. In marimo, reactive execution is part of the notebook’s semantic contract. A cell that reads globals defined by another cell is not just informally related to that upstream cell; it is computationally downstream. When the upstream definition changes, the notebook must account for the effect on downstream cells.

This is why reactive execution improves trust more than convenience. In data work, AI experimentation, and parameterized analysis, the primary failure mode is

often not a crash. It is a plausible-looking result backed by the wrong state. The output renders, the chart looks reasonable, and the model metric seems stable, but some upstream assumption changed without the dependent work being refreshed. Traditional notebooks make that class of error easy to create because the execution model tolerates divergence between visible code and live memory. marimo reduces that tolerance.

The phrase “no hidden state” is best understood operationally rather than rhetorically. A key part of the claim is that deleting a cell removes that cell’s variables from memory. In other words, notebook state is meant to stay aligned with notebook structure. If a definition no longer exists in the notebook, it does not continue to live as a ghost from an earlier run. That is a stronger guarantee than many notebook users are used to.

Deterministic reasoning without pretending everything is automatic Stronger execution semantics do not mean every notebook becomes perfectly reproducible by accident. A notebook can still be badly designed. It can mix unrelated concerns in the same cell, hide side effects behind helper calls, or rely on mutable objects in ways that blur dependency boundaries. The important conceptual upgrade is narrower and more practical: the notebook engine can derive affected computation from code relationships that are visible and inspectable.

That gives you a cleaner model of recomputation. If y depends on x, and an output depends on y, you should expect a change to x to reach both. If another branch of the notebook depends only on z, it should remain unaffected. You can reason about scope of impact by following dependency edges rather than by mentally replaying a session. This makes notebook behavior closer to the way you already reason about ordinary programs: changes have consequences because of data and control relationships, not because of their location in a document.

The mutation pitfall There is one conceptual edge case you should internalize early because it reveals the boundary of static dependency analysis. marimo tracks definitions and reads of global names, but it does not track runtime mutation of objects. If one cell defines a dataframe and another cell mutates that same dataframe in place, the dependency graph cannot fully represent the semantic effect of that mutation. You may get behavior that surprises you because the object changed without a corresponding rebinding of the global name.

A minimal pattern looks like this:

import marimo 

app = marimo.App() 

with app.setup: 
    import pandas as pd 

@app.cell 
def _(pd): 
    df = pd.DataFrame({"x": [1, 2, 3]}) 
    return (df,) 

@app.cell 
def _(df): 
    df["y"] = df["x"] * 2 
    return 

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

The second cell mutates df rather than producing a new object and returning it. That mutation is real at runtime, but the dependency model fundamentally reasons about named definitions and reads. The safer pattern is to perform mutation in the same cell that defines the object, or better, to avoid cross-cell mutation and instead create a new value that you explicitly return. Rebinding produces clearer dependencies than in-place mutation. This is less about stylistic purity than

about keeping the notebook’s conceptual graph aligned with what actually changes.

For advanced analytical work, that advice is especially important with pandas objects, model containers, and configuration dictionaries. These types invite incremental mutation, and traditional notebooks often rely on that flexibility. In a reactive notebook, mutation across cells weakens the correspondence between code structure and notebook behavior. If you want the dependency graph to remain trustworthy, prefer cells whose outputs are defined by their inputs without hidden object mutation elsewhere.

Expensive computation and the meaning of stale Reactive recomputation raises an obvious operational question: what if downstream work is expensive? A notebook might train a model, execute a long query, or materialize a large intermediate artifact. Automatic propagation is semantically attractive, but full eagerness can be costly.

marimo addresses that with a lazy runtime mode for expensive notebooks. Conceptually, lazy runtime changes the immediate consequence of an upstream edit. Instead of rerunning affected downstream cells right away, marimo can mark them as stale. “Stale” means the notebook knows a cell’s displayed result no longer corresponds to the current upstream state. That is a crucial concept because it preserves correctness signaling even when you defer recomputation for cost reasons.

You do not need the runtime configuration details yet to understand the mental model. The conceptual point is that marimo separates two concerns: dependency validity and execution cost. A downstream cell can be known to be out of date even if you choose not to recompute it immediately. That is much better than silently leaving an obsolete result on screen with no indication that the assumptions changed.

Why this matters for data and AI work Reactive dataflow semantics are not academic elegance. They target a class of failures that matter precisely in the workflows where notebooks are most heavily used. In feature engineering, model evaluation, prompt comparison, metric analysis, and parameter sweeps, a result is only as trustworthy as the path that produced it. If you must reconstruct that path from memory, the notebook remains fragile even when the code is sophisticated.

A dependency-driven notebook improves observability of reasoning. If a chart changed, you can ask which values it reads. If a metric drifted, you can inspect the cells upstream of that metric. If a deleted transformation should no longer affect the result, removing the cell should also remove its variables from memory. These properties do not replace disciplined experiment design or environment control, but they do remove a large category of notebook-specific ambiguity.

This is also why marimo is best understood as a reactive program editor rather than a container for snippets. A snippet collection invites local thinking: run this bit, inspect that output, keep moving. A reactive program forces global thinking: which symbols are defined, who consumes them, and what results are therefore downstream? That shift is exactly what makes interactive work more reproducible

without stripping away interactivity.

Anti-patterns that preserve the old notebook mindset Several habits keep readers from getting the full benefit of the model.

Assuming visible order equals execution truth. Visual order helps you read a notebook, but it is not the computational contract. If you rely on screen position rather than dependencies, you will misread how changes propagate.

Writing cells with vague dependency boundaries. A cell that reaches into many globals, mutates shared objects, or bundles unrelated steps becomes hard to reason about in dataflow terms. Prefer cells whose inputs and outputs form an understandable boundary.

Treating reactivity as convenience only. If you think reactivity merely saves reruns, you will miss its real value: it provides a principled relationship between code changes and result validity.

Importing manual-state habits unchanged. If you still reason primarily in terms of “what I happened to run,” you are using a reactive notebook with a non-reactive mental model. The notebook becomes much easier to trust once you reason from symbol dependencies first.

When you read a marimo notebook as a dataflow program, the notebook stops being a transcript of an interactive session and becomes a graph of published values and dependent computations. That is the mental move that makes reactive execution, stale state, and reproducibility feel coherent rather than incidental.

2.3 Understanding the Consequences of Pure-Python Notebook Files

A notebook file format seems like an implementation detail until it collides with the rest of your engineering workflow. That collision usually happens in ordinary places: a pull request full of metadata churn, a merge conflict in serialized output, an editor that understands Python imperfectly because the artifact is not really source

code, or a useful notebook function that cannot be reused cleanly without copying it into a module. Once the notebook becomes part of a team workflow rather than a private scratchpad, representation starts to matter as much as execution.

marimo makes a strong bet here: notebooks are stored as .py files, not .ipynb JSON documents. You can see the practical consequence immediately with a small file that carries both notebook code and inline dependency metadata.

# /// script 
# requires-python = ">=3.11" 
# dependencies = [ 
# "marimo", 
# "pandas", 
# ] 
# /// 

import marimo 

app = marimo.App() 

with app.setup: 
    import marimo as mo 
    import pandas as pd 

@app.cell 
def _(pd): 
    df = pd.DataFrame({"x": [1, 2, 3]}) 
    return (df,) 

@app.cell 
def _(df, mo): 
    mo.md(f"Rows: {len(df)}") 
    return 

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

That file is simultaneously Python source, a marimo notebook, and a self-describing artifact that can participate in sandboxed execution. You can work with it using standard Python execution and marimo-specific tooling:

python my_marimo_notebook.py 
marimo check my_marimo_notebook.py 
marimo edit --sandbox my_marimo_notebook.py 
marimo run --sandbox my_marimo_notebook.py

Those commands tell you something fundamental about the artifact. You are not editing a notebook document that happens to contain Python. You are editing Python that also functions as a notebook. That reversal has consequences for code review, repository hygiene, reuse, environment capture, and the psychological boundary between exploratory work and software engineering.

Why file representation changes collaboration A Jupyter notebook is formally a JSON document with a schema that includes cells, metadata, and serialized outputs. That representation is useful for notebook-oriented tooling, but it is not the native representation of Python source. When the artifact flows into version control, code review, and editor tooling, the mismatch becomes visible. A small code change can be surrounded by unrelated metadata movement. Outputs can dominate diffs. Merge conflicts can occur in structural JSON rather than in the code you intended to change.

A pure-Python notebook changes the review surface. Diffs operate on source lines. Comments, function definitions, imports, and string literals appear in the form your tools already understand. Even before you apply specialized notebook-aware tooling, ordinary Git and ordinary editors are interacting with a Python file rather than with a serialized document format.

This does not make every diff beautiful. If you write large, tangled cells, the diff will still be hard to review. If you keep unstable literals or noisy generated code in the notebook, reviewers will still struggle. The difference is that the artifact no longer adds a second layer of representational friction on top of whatever design problems your code already has.

Inspectability as a design property Inspectability is more than human convenience. It affects how confidently you can evolve analytical code. When a notebook is stored as Python, a reviewer can examine the code path directly. They can search symbols with normal editor features, jump to definitions, scan imports, and reason about changes at the level of executable text. That reduces the mental overhead of deciding whether the notebook contains stable logic or only transient experimentation.

Pure-Python storage also changes how you think about notebook authorship. A notebook stops being a sealed container whose internal structure only notebook-aware tools fully understand. It becomes a source artifact that can be read meaningfully in a terminal, code review interface, or standard IDE. That matters for teams where not every collaborator lives in the same notebook environment. A backend engineer, data engineer, or reviewer can still inspect the artifact without first translating it into a different mental format.

For research-heavy teams, this improves the durability of notebook work. The code remains legible even when the exact execution environment or UI context is absent. That is not the same as guaranteeing reproducibility, but it does preserve intelligibility, which is a prerequisite for reproducibility.

Tool interoperability becomes much more natural A Python file participates in the Python ecosystem almost by default. Linters, formatters, syntax highlighters, static analyzers, editor navigation, and search tools already know what to do with .py files. marimo reinforces that with notebook-aware validation through marimo check, but the important point is broader: the notebook artifact is no longer foreign to your toolchain.

That has concrete consequences even when you are working alone. You can open the notebook in a normal editor and still get useful feedback. Repository-wide searches include notebook logic without special handling. Symbol-based navigation works more like it does in scripts and modules. If your team uses pre-commit hooks, source indexing, or standard Python review conventions, the notebook fits into those habits more easily.

This matters because tooling shapes architecture. Developers often avoid putting important logic in notebooks not because notebook code is inherently inferior, but because the surrounding ecosystem handles it awkwardly. Pure-Python storage removes part of that pressure. You can keep interactive work close to the logic without immediately isolating the logic into another file solely to satisfy tooling.

The distinction is subtle but important. Pure-Python storage does not mean you should keep all logic in notebooks. It means you can decide where the logic belongs for design reasons rather than because the file format is fighting your tools.

Running a notebook as Python changes the artifact’s role Because the notebook is Python, you can execute it directly:

python my_marimo_notebook.py

That capability is easy to underestimate. In many notebook systems, “run the notebook” and “run the code as a Python program” are meaningfully different actions mediated by different artifact types. In marimo, the notebook file itself already lives in the same representational universe as scripts. That shortens the path between exploratory work and operational use.

The significance is not that every notebook should now be treated as a script. The significance is that the notebook can participate in script-like workflows without translation into another format. If a notebook writes files, performs a batch analysis, or serves as a reproducible computation artifact, you are not forced to export or reconstruct it before running it in a more ordinary way.

That makes mixed workflows much less awkward. An artifact can begin life as an interactive notebook, later run unattended for a repeated task, and eventually yield extracted reusable components, all without changing its fundamental file representation. The detailed engineering patterns for doing that well belong elsewhere, but the enabling condition is already visible here: the file is source code.

Importability narrows the gap between exploration and reuse Pure-Python storage also changes reuse. marimo supports normal Python imports for top-level functions and classes defined in notebooks, provided they satisfy documented constraints. In practice, that means you can write notebook logic that remains importable without converting the notebook into a separate module first.

A minimal import looks like this:

from my_notebook import my_utility_function

That line carries more weight than it appears to. In many notebook-centric workflows, useful code is first “discovered” in a notebook and only later “promoted” into importable Python after a manual extraction step. That extraction is often delayed because it interrupts flow, and the result is a growing body of hidden utility logic living in notebooks where other code cannot use it cleanly. With a Python-native notebook file, the boundary is thinner.

The constraints still matter. A top-level function or class must be defined in a way that preserves its importable identity, and it may only depend on symbols that are safe under marimo’s documented export rules. Those constraints

are useful because they force you to distinguish reusable definitions from notebook-local exploratory state. Reuse stops being a copy-paste habit and becomes an architectural choice you can express directly in the notebook artifact.

This does not remove the need for proper modules. If a piece of logic becomes central to many consumers, an ordinary package layout may still be the right long-term home. What changes is that you do not need a separate extraction ceremony merely to cross the boundary from “inside notebook” to “inside Python.”

Inline metadata turns a single file into a more complete artifact Pure-Python storage enables another capability that would be much more awkward in a notebook document: inline dependency metadata. In sandbox mode, marimo can track packages and versions in the notebook file itself using the PEP 723 style metadata block. That is why the # /// script header matters.

The practical effect is that the file can describe not only code, but also part of the environment required to run it. This is especially valuable for sharing a single artifact in contexts where creating or locating a separate project configuration would add friction. If a notebook is meant to be passed around as a self-contained analytical artifact, embedding Python version and dependency requirements directly in the file reduces ambiguity.

The workflow depends on sandbox mode and requires uv. That requirement is not a minor footnote; it reflects the fact that environment isolation and package resolution remain real concerns even when the notebook representation improves. Pure-Python storage makes environment capture easier to associate with the notebook, but it does not magically erase the complexity of dependency management.

That nuance matters. A portable source file is not the same thing as a reproducible execution environment. The former improves legibility and tool compatibility. The latter requires dependency and interpreter control. marimo’s sandbox mode helps connect those concerns, but you should keep them conceptually distinct.

Repository hygiene improves, but architecture still matters Teams often over-ascribe benefits to file format changes. A .py notebook is easier to diff, easier to review, and easier to run through familiar tooling than a serialized notebook document. None of those improvements guarantees good decomposition. You can still produce monolithic cells, unstable interfaces, hidden side effects, and notebooks that are painful to maintain.

The real gain is that the artifact no longer fights healthy repository practices. Branching, merging, and reviewing become more like they are for ordinary Python code. That makes it easier to treat notebooks as first-class project assets rather than

exceptional files that live outside normal quality controls.

For teams with mixed roles, this has an organizational effect as well as a technical one. Analysts can keep working interactively while engineers remain willing to review and integrate the artifact. The conversation shifts from “please move this into a real Python file” to “which parts should remain interactive, and which parts should become stable interfaces?” That is a better design conversation because it focuses on system boundaries rather than on a format incompatibility.

A useful contrast with .ipynb artifacts It helps to state the contrast plainly. A Jupyter notebook stores code, metadata, and outputs in a JSON notebook schema. That design supports rich notebook features, but it also means the notebook’s serialized form is not naturally aligned with the line-oriented source workflows that dominate Python engineering. marimo stores the notebook as Python source. The notebook remains notebook-capable, but its primary representation is now one the Python ecosystem already understands.

The gain is not aesthetic. It affects how the artifact behaves under Git, how easily you can grep it, whether standard Python tooling sees it as native input, and how readily code can move from interactive exploration into reusable structure. Once a notebook is Python at rest, many downstream engineering workflows become simpler because the file is no longer an exception.

Anti-patterns to avoid A few overcorrections are common once readers recognize the benefits of Python-native files.

Assuming the file format solves architectural debt. It does not. If the notebook has poor boundaries, the problems remain; they are just easier to inspect.

Keeping too much logic in giant cells because the file now “looks like code.” Source representation is not an excuse for notebook sprawl. Cell granularity and interface clarity still matter.

Confusing portability with reproducibility. A .py notebook moves more easily through developer tooling, but that does not ensure the same packages, interpreter, or data are present everywhere it runs.

Treating importability as a reason to avoid proper modules. Importing from notebooks is powerful, but stable shared logic may still deserve a dedicated package boundary.

When the notebook is stored as Python, the artifact participates more naturally in the practices that make software durable: readable diffs, conventional tooling, direct execution, selective reuse, and cleaner repository habits. That does not turn notebooking into ordinary module development, and it should not. It simply removes an unnecessary representational barrier between interactive computation and the rest of the Python engineering world.

2.4 Choosing Among Notebook, Script, Module, and App Roles

The same marimo artifact can be explored interactively in an editor, executed by the Python interpreter, imported for reuse, or served as an app. That flexibility is useful only if you decide which role is primary. Without that decision, a notebook tends to drift: exploratory cells accumulate side effects, reusable logic becomes entangled with UI code, and a stakeholder-facing interface inherits author-centric assumptions. The operational problem is not lack of capability. It is role confusion.

A compact example makes the overlap visible. The file below is a marimo notebook, but it is also plainly executable software.

import marimo 

app = marimo.App() 

with app.setup: 
    import marimo as mo 

@app.cell 
def _(): 
    title = "Quarterly sales review" 
    return (title,) 

@app.cell 
def _(mo, title): 
    mo.md(f"# {title}") 
    return 

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

You can interact with the same file through different operational surfaces:

marimo edit report.py 
python report.py 
marimo run report.py

Those commands do not imply the artifact should serve all roles equally well. They tell you the file can cross boundaries when needed. Your job is to decide what you are optimizing for: author exploration, unattended execution, reusable code, or user-facing interaction. The right answer determines how you structure cells, where you place logic, how you expose parameters, and what kinds of coupling you allow.

Start by choosing the dominant audience The cleanest first question is: who is the primary consumer of this artifact right now? If the answer is “me, while I investigate or iterate,” the notebook role is usually primary. If the answer is “an automated system or a repeated operational task,” the script role deserves priority. If the answer is “other Python code,” you are really building a module. If the answer is “a colleague who needs to interact with results without reading code,” you are closest to the app role.

This sounds obvious, but teams often skip it because marimo allows one file to do

many things. That is precisely why the question matters. When you do not choose a dominant audience, you accidentally optimize for conflicting needs. Exploratory work favors malleability and visible intermediate results. Module design favors stable interfaces and minimal incidental state. App delivery favors clear controls and code-hidden consumption. A single artifact can support all of these over time, but not all of them should drive the design at once.

Notebook role: exploration with visible code and outputs Choose the notebook role when the artifact exists mainly to support exploratory, interactive, reactive analysis. The author, or a close collaborator, needs to inspect code and outputs together, adjust parameters, compare alternatives, and follow dependencies as the notebook evolves. In this role, visibility is part of the product. The code is not hidden; it is one of the primary interfaces.

This is the natural fit for iterative data analysis, experiment design, feature engineering, benchmark exploration, and analytical debugging. You care about quick feedback, but you also care about the stronger correctness properties that come from dependency-driven recomputation. The notebook remains the center of gravity because the human operator is reasoning through the computation as it unfolds.

When notebook role is primary, design for legibility of analysis rather than for a polished consumer interface. Keep cells focused enough that dependency boundaries stay clear. Expose parameters in ways that help the author reason about changes. Accept that some outputs exist to explain computation rather than to present a finished result.

A common mistake is to hold onto notebook role too long. Once the work becomes mostly unattended or mostly consumed by others, author-oriented layout and variable naming stop being the right design drivers. That is the point to consider shifting the artifact’s primary role, even if you keep using the same file.

Script role: repeatable execution and side effects Choose the script role when the dominant need is repeatable execution rather than interactive inspection. Script-oriented work usually has side effects: writing files, producing terminal output, materializing reports, or participating in automation. The marimo guide explicitly highlights writing to disk and terminal output as motivating examples for running notebooks like scripts.

Because the notebook is a Python file, you can run it directly:

python report.py

That capability is valuable for jobs that began as interactive analyses and later became operational routines. You do not need to discard the notebook or manually port it into a separate script format before it can run unattended.

Still, a notebook used as a script should not look like an uncurated exploration log. If script role is primary, organize the file around execution boundaries and side-effect clarity. Ask where failures should surface, what inputs are expected, and which cells are performing irreversible actions. You want the notebook’s reactive

structure to remain understandable, but you also want the artifact to behave predictably when no human is steering it in an editor.

The script role is usually a bad fit when the main value lies in rich inspection of intermediate results. If the operator needs to stop frequently, inspect state, and tweak assumptions interactively, you are still closer to notebook role. Script mode becomes the better center when repeatability matters more than exploratory visibility.

Module role: reusable logic with explicit export boundaries Choose the module role when the most valuable part of the artifact is reusable logic that other Python code should import. marimo supports this, but only under specific constraints: a top-level function or class must live in a cell that defines exactly one function or class, and that definition may depend only on symbols from the setup cell or other top-level symbols.

A minimal reuse pattern looks like this:

from report import compute_summary

That import line signals a different design discipline. The artifact is no longer only an interactive workspace. It is also part of a programmatic interface consumed elsewhere. Once that happens, cell boundaries start to matter differently. Notebook-local convenience variables, exploratory intermediates, and incidental UI logic should not leak into definitions that are meant to be reused.

Module role is often the right answer when you have discovered useful business logic, transformation routines, validators, or small domain abstractions inside a notebook and want to reuse them without copy-paste extraction. It is especially effective for logic that still benefits from being demonstrated or exercised in the notebook itself.

At the same time, module role has limits. If a large portion of your system now depends on the notebook as a library, you should ask whether the notebook remains the right home for that logic. The most robust pattern is usually hybrid: keep complex reusable logic in ordinary Python modules and let marimo orchestrate them. The runtime guidance explicitly recommends that architecture. A notebook can remain the DAG-oriented coordinator or interactive front end while core logic lives in conventional module files with their own tests and interfaces.

App role: interaction for consumers, not authors Choose the app role when the primary user is not the notebook author but a consumer who needs to interact with outputs. In marimo, you can serve that experience with:

marimo run report.py

In app mode, the default emphasis shifts away from code visibility. The consumer interacts with the notebook’s outputs and controls rather than with its implementation. That makes app role a strong fit for internal reports, lightweight

analytical tools, parameterized demos, and decision-support interfaces for stakeholders.

The design priorities change immediately when app role becomes primary. You need clearer parameter surfaces, clearer failure presentation, and stronger discipline about what counts as public interaction. A UI control that is acceptable as an author convenience may be a poor public interface. A cell that mixes heavy computation with explanatory debugging output may be tolerable in notebook mode but awkward in app mode.

One subtle but important distinction is runtime behavior. Runtime documentation notes that lazy runtime settings do not affect behavior when notebooks are shared as apps with marimo run. You should therefore treat app semantics separately from notebook editing semantics. A notebook that feels comfortable to edit under one runtime posture may still need different design decisions once it becomes an app surface for others.

App role is not a mandate. The fact that a notebook can be served does not mean it should be. Many analytical artifacts are most valuable as author-facing notebooks or script-like batch jobs. Promote to app mode when code-hidden interaction is genuinely part of the requirement, not merely because it is available.

A practical decision matrix Four questions usually identify the right primary role quickly.

Who triggers execution? If you trigger computation manually while reasoning through the analysis, favor notebook role. If a scheduler, CLI invocation, or repeatable operational action triggers it, favor script role. If imported code triggers it, favor module role. If an end user triggers it through controls, favor app role.

Who changes parameters? Author-only tuning points toward notebook role. Stable operational inputs point toward script role. Programmatic callers point toward module role. Non-author consumers point toward app role.

What level of code visibility is acceptable? Visible code and outputs suit notebook role. Hidden implementation with user-facing controls suits app role. Script and module roles usually sit between those extremes: code remains visible to developers, but not necessarily as part of the end-user interaction model.

Where do failures need to surface? Notebook failures can surface to the author during exploration. Script failures must be legible in unattended execution contexts. Module failures must compose cleanly with other Python code. App failures must be intelligible to users who are not expected to debug notebook cells.

If two roles still seem equally plausible after these questions, the artifact is probably in transition. That is normal. Pick the role that dominates today, then shape the file so the transition to the next role remains clean.

Hybrid architecture is usually the most stable long-term pattern The most maintainable marimo projects often separate core logic from orchestration and interaction. Put complex data transformations, domain rules, and reusable algorithms in ordinary Python modules. Let the marimo notebook import that logic

and arrange it into a reactive analysis, a script-like run, or a user-facing app surface.

A simple layout makes the pattern concrete:

project/ 
    analysis_lib.py 
    report.py

analysis_lib.py can hold reusable functions and classes. report.py can remain the marimo artifact that imports the library, presents controls, renders outputs, and coordinates execution. That split gives you cleaner tests, more stable reuse boundaries, and a notebook that stays focused on orchestration rather than absorbing every piece of domain logic.

This hybrid model also reduces role conflict. The module file can optimize for API clarity and reuse. The notebook file can optimize for interactive reasoning or presentation. You preserve marimo’s flexibility without forcing one artifact to carry incompatible concerns.

Programmatic execution reinforces that a notebook is software The role discussion is broader than CLI commands. marimo notebooks are built around an application object, and programmatic execution is available through app.run(defs=...). Even when you do not use that entry point directly, it is conceptually useful because it reminds you that the notebook is an executable software artifact, not just an editor document.

A minimal structure looks like this:

import marimo 

app = marimo.App() 

@app.cell 
def _(): 
    value = 42 
    return (value,) 

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

That model broadens your design options. A notebook can be launched interactively, invoked through Python execution, or treated as part of a larger software context. The practical implication is that you should avoid hard-coding assumptions that only make sense in one surface if the artifact is likely to serve another later. Preserve clean boundaries around inputs, outputs, and side effects so that role changes remain low-friction.

Common anti-patterns Several mistakes recur when teams first appreciate how many roles a marimo artifact can play.

Refusing to choose a primary role. This usually produces notebooks that are awkward to explore, unreliable to automate, brittle to import, and confusing to present as apps.

Using notebook cells as a de facto public API. Reusable interfaces should be explicit. Do not assume other code should depend on arbitrary cell state just because the artifact is Python.

Optimizing too early for app delivery. If the work is still exploratory, app-first constraints can freeze the wrong abstractions and make iteration slower.

Keeping exploratory variables where stable parameters should exist. If script or app role is becoming primary, make the control surface intentional rather than leaving it embedded in author-local cell state.

Ignoring cost semantics across roles. An exploratory notebook may tolerate expensive recomputation differently than a served app or unattended script. Role selection should influence where you place expensive work and how visibly you expose it.

The strongest use of marimo comes from recognizing that notebook, script, module, and app are not competing identities but operational roles the same artifact can adopt. Once you choose which role dominates, the design becomes clearer: visible reasoning for notebooks, explicit side effects for scripts, clean export boundaries for modules, and intentional user interaction for apps.

Chapter 3
Reactive Execution, State, and Correctness

Experienced notebook users often discover too late that most analytical mistakes are not numerical-they are causal. This chapter treats marimo as an execution system with explicit semantics, showing how dependency inference, rerun propagation, stale marking, and state cleanup combine to make notebook behavior auditable rather than accidental. The central message is that correctness in reactive notebooks comes from structure: once readers understand the graph and design cells around deterministic boundaries, they can control cost, prevent state leaks, and build notebooks that remain reliable under continuous change.

3.1 Dependency Inference and Graph Construction

A notebook stops being trustworthy the moment its outputs can outlive the code path that supposedly produced them. You have likely seen the failure mode: a parameter changes, a helper function is edited, a plot still renders, and nothing obvious signals that one downstream result still depends on an older intermediate value. The problem is not arithmetic. It is causal structure. marimo addresses that problem by treating a notebook as a program with an explicit dependency graph rather than as a transcript of commands sent to a long-lived interpreter.

A small example makes the model concrete. Save the following file as deps.py and open it with marimo:

import marimo 

app = marimo.App() 

@app.cell 
def __(): 
 import numpy as np 
 return np, 

@app.cell 
def __(): 
 scale = 2.0 
 shift = 1.0 
 return scale, shift 

@app.cell 
def __(np, scale, shift): 
 x = np.arange(5) 
 y = scale * x + shift 
 return x, y 

@app.cell 
def __(x, y): 
 pairs = list(zip(x.tolist(), y.tolist())) 
 return pairs, 

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

Launch it with:

marimo edit deps.py

If you change scale or shift, marimo can determine exactly which cells depend on those names and can update the affected descendants. The key detail is that this structure is inferred from code, not from the order in which you happened to run cells earlier in the session.

Cells as graph nodes

marimo models a notebook as a directed acyclic graph. Each node is a cell. Each edge represents a data dependency between cells. That sounds simple, but the operational consequence is substantial: execution structure comes from symbol flow, not from screen position, not from execution history, and not from whichever cell you manually reran five minutes ago.

The graph is built from two sets associated with each cell:

Definitions (defs): global names introduced by the cell.

References (refs): global names read by the cell but not defined within it.

An edge exists from cell A to cell B when B references a global name defined by A. In the example above, the cell that defines scale and shift is upstream of the cell that computes y. The cell that computes x and y is upstream of the cell that creates pairs. That induced graph gives marimo a valid execution order and a precise invalidation path when code changes.

This vocabulary matters because it replaces vague notebook folklore with inspectable semantics:

Upstream cells define names you consume.

Downstream cells consume names you define.

Transitive dependencies are dependencies of your dependencies.

Invalidation means a downstream result is no longer justified by current upstream code or values.

Topological execution order is an order that respects all dependency edges.

Once you read a notebook this way, visual layout becomes secondary. The real structure is the graph.

How marimo infers edges

marimo determines dependencies by analyzing the code in each cell without running it. That distinction is essential. Dependency discovery does not require executing the notebook and observing what happened at runtime. Instead, marimo inspects the code to determine which global names a cell defines and which global names it references.

That static analysis gives marimo several advantages. First, dependency structure exists before execution, so the notebook has an execution plan independent of ad hoc user order. Second, graph errors can be identified as structural problems rather than discovered late as confusing runtime behavior. Third, the graph remains tied to the current source code, which makes causality inspectable.

The Cell API exposes the same conceptual model. A cell has defs and refs properties, and Cell.run(**refs: Any) shows the operational contract clearly: a cell runs against the values of its references and produces outputs plus newly defined names. You do not usually manipulate Cell directly when authoring notebooks, but the API confirms that marimo’s execution model is based on explicit symbol inputs and outputs, not on an ambient mutable workspace.

Why execution order is not notebook order

Traditional notebook habits train you to think in terms of sequence: top to bottom when clean, some private out-of-order history when exploratory. marimo breaks that equivalence deliberately. Two cells that appear adjacent may be unrelated if they share no symbols. Two cells far apart may be tightly coupled if one references a name defined by the other.

That changes how you diagnose behavior. If an edit triggers recomputation you did not expect, inspect symbol ownership first. Ask which names the edited cell defines, then identify all consumers of those names, including indirect consumers. If an edit fails to trigger an expected update, the notebook may not actually encode the dependency you assumed. The usual cause is not that marimo missed a hidden relationship; it is that the relationship lives in mutable ambient state, external I/O, or a broad helper object that obscures fine-grained lineage.

The graph therefore doubles as a correctness mechanism and a design pressure. If your conceptual dependency is not visible as a symbol dependency, the notebook cannot reason about it cleanly.

Granularity determines recomputation boundaries

A cell is not just a container for code. It is a node with execution cost and blast radius. The set of names a cell owns determines how broad its downstream impact will be. This is where cell design becomes architectural.

Consider two approaches:

One cell defines raw_df, clean_df, model, and plot_theme.

Four cells define those names separately, each with narrow, coherent responsibility.

In the first design, any edit to that one cell potentially invalidates every downstream consumer of any of those names. marimo cannot treat the internal pieces as independent dependency roots because, structurally, they are not independent; they are bundled into one node. In the second design, an edit to plotting configuration need not fan out into model-related recomputation if the names live in separate cells with separate consumers.

This is the first practical rule for shaping the graph: group code by dependency coherence, not by superficial convenience. A good cell owns names that tend to change for the same reason and that are consumed by roughly the same downstream region.

Import cells as dependency roots

Import cells often become stable roots in the graph. A cell such as

@app.cell 
def __(): 
 import pandas as pd 
 import numpy as np 
 return np, pd

defines names that many other cells reference. That is acceptable because imports are usually stable and cheap. They form broad roots with low churn. The same pattern becomes problematic when you mix imports with volatile values:

@app.cell 
def __(): 
 import pandas as pd 
 import numpy as np 
 sample_frac = 0.1 
 return np, pd, sample_frac

Now a change to sample_frac touches the same cell that owns np and pd. Even if imports themselves do not semantically change, the dependency boundary is coarser than it needs to be. Separate stable infrastructure from frequently edited parameters. That gives you narrower invalidation and a graph that better reflects intent.

Multiple definitions and cycles are structural failures

marimo relies on a graph with a valid execution order. Two notebook patterns break that requirement directly.

The first is multiple definitions: more than one cell defines the same global name. If two cells both define df, the graph no longer has a single authoritative producer for consumers of df. That is not a style issue. It destroys the notebook’s ability to infer a reliable execution structure.

The second is cyclic dependency: cell A depends on a name from cell B while cell B depends on a name from cell A, directly or indirectly. A cycle means there is no topological order that satisfies all edges.

These are breaking errors because they prevent the notebook from being treated as a deterministic program. They also block predictable reactive updates and script-style execution. If you hit either problem, the fix is not to rerun in a clever order. The fix is to refactor the graph.

Reading graph shape from code

You can usually predict the graph by inspection if you read cells as producers and consumers of names. Use a quick four-step pass:

List the global names each cell defines.

List the global names each cell reads from outside itself.

Draw an edge from each producer to each consumer.

Follow transitive downstream paths from any name you expect to change often.

Applied to a small analysis notebook, the graph often looks like this in prose:

load_data defines raw_df

prepare references raw_df and defines clean_df

features references clean_df and defines X

train references X and defines model

plot references clean_df and model

That graph tells you immediately that changing data acquisition has broad downstream impact, while changing plotting code should ideally remain isolated near the leaves. If the actual cell layout does not reflect this conceptual structure, refactor until it does.

Structural design guidance

The most useful mental split is between interface cells and volatile cells. Interface cells define stable names that many other cells consume: imports, configuration objects, canonical cleaned datasets, reusable helper functions. Volatile cells are where you iterate quickly: a temporary analysis branch, an alternate visualization, an experimental transformation.

You want stable interfaces to have narrow, intentional outputs and low edit frequency. You want volatile work to sit downstream of those interfaces, where experimentation does not disturb unrelated branches. If you edit an interface cell constantly, it is probably too broad. If a volatile cell exports many names used all over the notebook, it has accidentally become infrastructure.

Common anti-patterns

God cells. A single cell that loads data, defines helper functions, computes aggregates, and renders a chart creates a large node with a large blast radius. Any edit invalidates too much, and the graph cannot express the distinctions you care about.

Mixed acquisition, transformation, and presentation. Data loading changes for different reasons than feature engineering; feature engineering changes for different reasons than plotting. Collapsing them into one cell forces marimo to treat those concerns as one dependency boundary.

Overly broad dependency hubs. A cell that defines many unrelated names turns every downstream consumer of any one of those names into a descendant of the same coarse node. When reruns feel surprisingly wide, this is often the root cause.

Shadowed or recycled names. Reusing generic names such as df, data, or result across refactors obscures lineage even when marimo rejects multiple active definitions. Use names that encode role and stage so the graph remains legible to humans.

Helper definitions scattered near call sites. Local proximity can improve reading in a script, but in a reactive notebook it can fragment your mental model if helper functions that conceptually form a stable library are sprinkled throughout volatile analysis cells. Consolidate stable helpers into clear producer cells.

Refactoring changes graph shape

When you split a coarse cell into finer cells, you are not merely improving style. You are changing the graph’s topology. That can reduce unnecessary recomputation, isolate expensive branches, and make lineage auditable.

Suppose one coarse cell does all of the following:

@app.cell 
def __(pd): 
 raw_df = pd.read_csv("events.csv") 
 clean_df = raw_df.dropna() 
 summary = clean_df.groupby("kind").size() 
 return raw_df, clean_df, summary

A small edit to the summary logic forces the same cell to be considered changed, even though data loading and cleaning are conceptually separate stages. Refactor it into three cells, and you create independent boundaries:

@app.cell 
def __(pd): 
 raw_df = pd.read_csv("events.csv") 
 return raw_df, 

@app.cell 
def __(raw_df): 
 clean_df = raw_df.dropna() 
 return clean_df, 

@app.cell 
def __(clean_df): 
 summary = clean_df.groupby("kind").size() 
 return summary,

That restructuring communicates more than intent. It gives marimo a graph with narrower propagation paths.

A practical prediction workflow

When a notebook reruns more widely than expected, use a disciplined inspection flow instead of guessing.

First, pick one edited cell and write down every name it defines. Ignore what the cell does; focus on what it exports. Second, locate every direct consumer of those names. Third, extend the search transitively through the graph until you reach leaves. Fourth, classify each descendant by cost: cheap, moderate, expensive. If a cheap tweak reaches expensive descendants, inspect whether the edited cell owns too much or whether an intermediate cell exports an overly broad interface.

Apply that flow to three common edits:

Parameter cell edit. If a cell defines only threshold, then only consumers of threshold and their descendants should be affected. If unrelated analyses rerun, the threshold cell probably also defines names they consume.

Helper function edit. A stable helper function often has many consumers. Expect broad invalidation if you change its implementation. If that breadth feels wrong, the function may be overloaded and should be split into narrower helpers with more specific contracts.

Data acquisition edit. A cell that reads from a file, database, or API is usually an upstream root with naturally large impact. That broad fan-out is often correct. The design question is whether transformations and visualizations have been isolated well enough that only truly dependent branches update.

This procedure also exposes hidden assumptions. If you cannot predict the descendants of a cell by reading symbol producers and consumers, the notebook is relying on structure that the graph cannot see clearly. That usually means mutable shared objects, ambient configuration, or external state has crept into the workflow and needs a sharper boundary.

marimo’s graph model rewards a style of notebook authoring that is close to good software modularity but tuned for interactive analysis: one producer per global name, acyclic flow, stable interfaces for shared concepts, and narrow cells where changes should stay local. Once you read the notebook as a dataflow graph instead of a page of code fragments, recomputation stops feeling magical or arbitrary and starts looking like a direct consequence of the names each cell chooses to own.

3.2 Automatic Reruns, Stale Marking, and Cost Control

Reactive correctness becomes operationally interesting when downstream work is expensive. A notebook that recomputes a filtered table and redraws a small chart after every upstream change feels fluid. A notebook that retrains a model, reissues a warehouse query, or rebuilds embeddings after a one-line parameter tweak feels

broken even if its dependency logic is perfectly correct. The engineering problem is not whether dependencies exist; it is how the runtime should respond once a dependent cell becomes invalidated.

A compact marimo notebook makes the trade-off visible. Save the file as reruns.py:

import marimo 

app = marimo.App() 

@app.cell 
def __(): 
 import marimo as mo 
 import numpy as np 
 import time 
 return mo, np, time 

@app.cell 
def __(mo): 
 window = mo.ui.slider(1, 20, value=5, label="window") 
 return window, 

@app.cell 
def __(window): 
 n = window.value 
 return n, 

@app.cell 
def __(np, n): 
 data = np.arange(1, 101) 
 filtered = data[data % n == 0] 
 return filtered, 

@app.cell 
def __(filtered, time): 
 time.sleep(3) 
 expensive_summary = int(filtered.sum()) 
 return expensive_summary, 

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

Open it with:

marimo edit reruns.py

Move the slider a few times. In the default reactive behavior, the cells that depend on window.value update according to the inferred graph. The cheap filter cell reruns, and the artificial three-second summary cell reruns too because it is a descendant. That behavior is semantically correct: when a cell is run, all other cells that reference any global variables it defines run automatically. Put in graph terms, when a cell runs, its descendants run automatically.

Now switch the runtime setting to:

Runtime > On Cell Change = lazy

Repeat the interaction. The upstream values still change, but expensive descendants are marked stale instead of autorunning. The notebook does not pretend those outputs are current. It preserves the program-state guarantee by making staleness explicit rather than silently leaving old results on screen as if nothing happened.

Invalidation, freshness, and two correct responses

Once you read notebooks as dependency graphs, a change to an upstream cell has a precise meaning: every descendant that depends on the changed definitions becomes invalidated. Invalidated does not necessarily mean immediately recomputed. It means the current output can no longer be assumed to reflect the current upstream state.

marimo supports two correctness-preserving responses to invalidation.

Automatic rerun: descendants execute immediately after the upstream change. This maximizes freshness and preserves a conversational workflow.

Stale marking: descendants remain visible but are marked stale. This preserves causal truth while deferring recomputation cost.

The distinction matters because freshness and correctness are not identical. Freshness means the output has been recomputed from the current inputs. Correctness in the weaker operational sense means the system does not misrepresent an outdated result as current. Lazy mode keeps that guarantee by making dependent outputs explicitly outdated rather than invisibly wrong.

This is why stale state is not a cosmetic convenience. It is a deliberate signal in the execution model. If a result is stale, you know exactly what you are looking at: a previously computed output whose upstream dependencies have changed since it was produced.

Why eager propagation is often the right default

For light, deterministic work, autorun is the best interaction model. It matches the spreadsheet intuition that changing an input updates formulas that depend on it. If the descendants are cheap enough, immediate recomputation gives you a tighter feedback loop and reduces the need to think about execution control at all.

Typical eager-rerun candidates include:

slider-driven filtering over an in-memory dataset

lightweight aggregation and formatting

plot rendering over already materialized data

small helper transforms used during active exploration

These cells benefit from immediacy because the user expectation is high-frequency interaction. If a slider adjusts a threshold and the resulting chart takes 30 milliseconds to update, you gain clarity by rerunning automatically. The notebook feels continuous because the cost is negligible relative to human interaction time.

UI elements participate in the same reactive semantics. If a cell references a widget value, that cell depends on the widget just as surely as it depends on any other upstream definition. There is no separate callback system required to establish the dependency. A widget changes, the bound value changes, and only the impacted descendants are candidates for rerun or stale marking.

Why lazy mode exists

Expensive descendants change the economics completely. A tiny upstream edit can imply:

a remote query that consumes rate limits or money

a model-training step that takes minutes or hours

a large feature-materialization pass over many partitions

an external API call with side effects or operational risk

In those cases, immediate recomputation can be more harmful than helpful. You still want the notebook to preserve dependency-consistent state, but you do not want every exploratory nudge to trigger heavy work. Lazy mode addresses exactly that tension. It preserves program-state guarantees while preventing accidental execution of expensive descendants.

The important nuance is that lazy mode is not an escape from dependency semantics. The graph remains the same. The same cells are invalidated. The same descendants are identified. Only the runtime response changes: instead of executing all descendants automatically, marimo marks them stale and leaves the decision to recompute in your hands.

That design is stronger than ad hoc manual discipline. The notebook still knows which outputs are no longer current. You do not need to remember which cells might have been affected by a parameter tweak. The stale marker carries that burden explicitly.

Use cost and expectation as primary decision signals

Two signals dominate rerun policy decisions: recomputation cost and user expectation of immediacy.

Recomputation cost includes more than CPU time. Count remote I/O, warehouse credits, queue contention, memory pressure, startup overhead, and side-effect risk. A notebook cell that completes in two seconds but launches a paid remote query on every keystroke is expensive in a way a local plot redraw is not.

User expectation of immediacy comes from interaction style. Sliders, toggles, and small parameter tweaks create a high expectation that outputs update immediately. Batch operations such as model fitting, large exports, or data refreshes do not carry the same expectation. Users tolerate explicit refresh steps when the work is obviously substantial.

Once you place a cell on those two axes, the design choice usually becomes clear:

Low cost, high immediacy: rerun eagerly.

High cost, low immediacy: allow stale state and rerun consciously.

High cost, high immediacy: redesign the graph, isolate the expensive branch, or introduce caching.

Low cost, low immediacy: either policy can work; choose the one that keeps the notebook easier to reason about.

The hard quadrant is high cost and high immediacy. If a fast-moving UI control fans out into a slow downstream branch, the notebook structure is signaling a decomposition problem. Do not solve that by forcing users to accept sluggish interaction. Split the computation so the interactive path stays light and the heavy path is triggered less often or fed from an intermediate artifact.

Operational anti-patterns

Several notebook structures produce poor rerun behavior even when the dependency graph itself is valid.

Hidden expensive work inside seemingly lightweight cells. A cell named summary that actually fetches data from a remote service is a trap. Readers assume a cheap local aggregation, but a minor upstream change now incurs network cost.

Coupling heavy training loops to high-frequency controls. If a slider-driven parameter cell feeds directly into a model-training cell, every interaction either becomes painfully slow in autorun mode or constantly stale in lazy mode. Insert an intermediate boundary so exploratory controls drive lightweight diagnostics, while training is isolated behind an intentional trigger point.

Treating stale markers as ignorable decoration. A stale output is not “almost fresh.” It is a visible sign that the result is no longer justified by current upstream values. If the stale output could mislead a reader or operator, rerun it or redesign the notebook to reduce the stale region.

Branch entanglement. If one parameter cell feeds a dozen downstream branches, a minor adjustment may invalidate wide swaths of the notebook. Sometimes that is correct. Often it means the parameter cell owns unrelated settings that should be split so each branch depends only on what it actually needs.

A practical redesign flow

When rerun behavior feels wrong, inspect the notebook as a costed graph rather than as a page of cells.

Start by classifying cells into three rough tiers:

Cheap: sub-second local transforms, formatting, simple plots

Moderate: multi-second joins, larger aggregations, local model inference

Expensive: remote queries, long training jobs, large exports, effectful writes

Then edit one upstream parameter and observe the affected descendants. In autorun mode, note which descendants rerun. In lazy mode, note which descendants become stale. The key question is not merely “what changed?” but “did the invalidation boundary match the notebook’s conceptual structure?”

If a cheap parameter change reaches an expensive descendant unexpectedly, use one of these redesign moves:

Split a broad producer cell

A cell that defines both interactive parameters and heavyweight control values creates unnecessary fan-out. Separate them so the cheap exploratory path has its own narrower producers.

Materialize an intermediate result

If several expensive branches depend on the same moderate computation, isolate that intermediate product. Then lightweight views can rerun eagerly while downstream heavy consumers remain stale until explicitly refreshed.

Separate interaction from execution

UI controls should usually feed quick previews, slices, or diagnostics. Keep long-running training or export cells downstream of a more deliberate boundary. The graph should encode the difference between “change a view” and “launch a substantial job.”

Isolate side effects

If a cell writes to external systems, treat it as an operational boundary. Even when the pure computation leading up to it can rerun freely, the effectful step should usually not be tied to every upstream interaction. The details of effect isolation matter even more when you formalize deterministic notebook design later, but the cost-control signal already appears here.

A concrete review procedure

Use this review loop on any notebook that feels sluggish or unpredictably wide in its reruns:

1.

Pick one upstream cell that changes often.

2.

List its direct descendants and transitive descendants.

3.

Mark each descendant as cheap, moderate, or expensive.

4.

Switch between autorun behavior and Runtime > On Cell Change = lazy.

5.

Edit the upstream cell and compare the resulting propagation.

6.

Refactor any path where a high-frequency edit reaches expensive work without clear user intent.

That procedure is especially useful for notebooks with interactive controls. A slider, text input, or toggle often reveals structural flaws faster than static code reading does, because repeated UI interaction stresses the invalidation path. If the notebook feels responsive under real interaction, the graph boundaries are usually healthy. If it feels like every small movement wakes up a data center, the graph is too coarse or the expensive branch is too close to the interaction surface.

Comparison point: lazy modes are not all the same

Other reactive notebook systems expose lazy behavior too. IPyflow, for example, documents a %flow mode lazy control. The overlap is useful conceptually: both systems acknowledge that dependency-aware invalidation does not always imply immediate recomputation. The important difference is that lazy behavior sits on top of each system’s own inference model. marimo’s stale marking rests on its statically inferred cell graph and the same symbol-based dependency rules that drive autorun. That makes lazy mode a runtime policy choice over a fixed structural model, not a separate execution paradigm.

Choose policies that preserve both trust and pace

The goal is not to minimize recomputation at all costs. A notebook that never reruns anything quickly becomes misleading. The goal is to align runtime behavior with the economics and semantics of the work. Cheap, user-facing descendants should feel immediate. Expensive or operationally risky descendants should remain dependency-consistent without being triggered accidentally. When you get the boundaries right, autorun supports fluent exploration, lazy mode protects you from wasteful cascades, and stale outputs communicate exactly where freshness has been deferred.

3.3 Eliminating Hidden State

Notebook failures are often causal before they are numerical. A result looks plausible, a plot still renders, and the code on screen appears coherent, yet the live process is carrying values that no longer have any legitimate source in the current notebook. A deleted helper function still exists in memory. A dataframe from an earlier execution continues to feed downstream cells after a refactor. A partial rerun leaves outputs that belong to no consistent program state. Hidden state is exactly that class of problem: live values, bindings, or effects that influence behavior without being justified by the notebook’s present source and dependency structure.

You can expose the issue with a tiny notebook. Save this as hidden_state.py:

import marimo 

app = marimo.App() 

@app.cell 
def __(): 
 x = 10 
 return x, 

@app.cell 
def __(x): 
 y = x + 5 
 return y, 

@app.cell 
def __(y): 
 print(f"y={y}") 
 return 

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

Open it with:

marimo edit hidden_state.py

Now remove the first cell that defines x. In a traditional notebook workflow, the old binding can remain alive in kernel memory, which means downstream cells may still appear to work for the wrong reason. marimo explicitly takes the opposite approach: deleting a cell scrubs its variables from program memory. That behavior is not a convenience feature. It is part of the correctness model. If the notebook no longer defines x, the runtime should not preserve a ghost binding that makes downstream code look valid.

What hidden state actually means

Hidden state is often described too loosely, as if any mutable object or any remembered value counts. That blurs together very different risks. The precise concern is narrower and more useful: a value is hidden state when it remains semantically active even though the notebook’s current source no longer justifies its existence or current meaning.

Three common situations produce it:

Deleted or renamed definitions survive in memory.

Execution order diverges from dependency order.

Outputs remain visible after upstream meanings change.

The first is the classic ghost binding problem. The second appears when you rerun a later cell without refreshing its true prerequisites. The third is subtler: a displayed result may look authoritative even though upstream inputs or code have changed since it was computed.

marimo’s model attacks the first two directly. Reactive recalculation replaces manual rerun discipline, which reduces out-of-order execution hazards. Variable cleanup on deletion addresses the ghost-binding problem at the source. The result is a stronger alignment among code, outputs, and program state.

Consistency across code, outputs, and state

The core guarantee to keep in mind is consistency across code, outputs, and program state. That phrase is stronger than “recompute things when convenient.”

It says the notebook should behave like a coherent program derived from current source, not like an interpreter session with a partially remembered history.

Dependency inference gives the runtime enough structure to know what depends on what. Automatic reruns and stale marking determine how invalidation is surfaced. Hidden-state elimination adds the missing lifecycle discipline: if a definition disappears, the runtime removes the corresponding variables from memory instead of leaving them behind as unjustified survivors.

That changes how you evaluate notebook trustworthiness. You no longer need to ask, “Did I rerun everything in the right order?” in the same way you would in a manual REPL-like workflow. You ask a sharper question instead: “Does the current source, together with explicit dependencies and visible stale signals, fully justify the live state I am observing?”

Deleting a cell versus commenting around it

The cleanup guarantee is easiest to appreciate during refactoring. Suppose you had a cell that defined a preprocessing helper and a downstream cell that consumed its output. You then decide to replace that helper with a different design. If you delete the old producer cell, marimo scrubs its variables from program memory. That forces the notebook to tell the truth immediately: either downstream cells adapt to the new producer or they become invalid because the old name no longer exists.

That is exactly what you want during structural change. It prevents a dangerous intermediate state in which the notebook text claims one thing while the runtime still behaves according to an earlier version.

Be careful, though, not to overgeneralize the guarantee. Cleanup is about variables justified by cells. It does not magically reverse every external effect. If a deleted cell previously wrote a file, updated a database row, or created a remote table, those external effects do not vanish just because the local binding is scrubbed. Hidden-state elimination narrows the notebook-local state problem; it does not erase the outside world.

Why stale output and hidden state are related but distinct

A stale output is not the same thing as hidden state. Staleness is visible. Hidden state is dangerous precisely because it is not visible in the notebook’s present source model. When marimo marks a downstream cell stale after an upstream change, it is declaring that the output is outdated. That is an honest state. The notebook remains trustworthy because the inconsistency is explicit.

Hidden state is more corrosive. It happens when a value continues to participate in computation without an active, justified source definition. A stale chart that is visibly marked stale is annoying but intelligible. A downstream result that quietly depends on an already deleted value is much worse, because it presents false confidence.

This distinction also explains why stale marking belongs to correctness, not merely performance. A stale marker is the system refusing to turn a dependency change into a silent lie.

Jupyter-style kernel memory versus dependency-consistent memory

If you are migrating from Jupyter, the contrast is practical rather than philosophical. In a kernel-driven notebook, execution history can outrank source order. The active state is the sum of cells you happened to run, in whatever order, plus any objects that survived edits. That model is flexible for experimentation, but it makes trust difficult because the runtime may be carrying bindings that have no obvious textual justification.

marimo constrains that freedom in exchange for consistency. The notebook behaves less like a shell transcript and more like a structured program. Cells do not merely emit values into an amorphous namespace and hope the operator maintains discipline. Their definitions and references establish what can exist, what must rerun, and what should disappear when removed.

That difference becomes most valuable during maintenance. Exploratory notebooks can tolerate occasional ambiguity while you are alone at the keyboard. Shared analytical artifacts cannot. Once another person, a scheduled run, or an application deployment relies on the notebook, hidden state stops being a nuisance and becomes an operational liability.

Mutable objects can reintroduce hidden assumptions

Even with strong cell cleanup semantics, you can still create local state that is hard to reason about if you mutate shared objects across cells. Consider a dataframe defined in one cell and modified in place in another. The dependency graph can tell you which cell references the dataframe name, but it cannot make in-place mutation conceptually pure. Readers now have to reason about not just where the object was defined, but which downstream code may have altered it.

That pattern is not always wrong, but it raises the burden of proof. If a cell mutates a shared object, the meaning of every downstream consumer depends on execution details and object identity, not just on explicit symbol flow. The notebook may still be dependency-consistent in a narrow sense, yet harder to audit because the lineage of values is now entwined with mutation history.

Prefer transformations that produce new objects instead of mutating shared ones in place. That choice keeps the live state closer to what the graph suggests and makes cleanup semantics more effective because each named object has a clearer lifecycle.

The trap of unnecessary custom state

marimo provides mo.state, but the documentation is explicit that it is advanced and unnecessary in the vast majority of cases. In over 99% of cases, you do not need it. That warning belongs squarely in a discussion of hidden state because custom mutable state is one of the easiest ways to smuggle notebook-local memory back into places where the reactive graph was already sufficient.

The usual mistake is to add custom state for UI interactions that already have built-in reactive values. If you have a slider, text input, or other widget, read its .value in dependent cells. That keeps the dependency visible and lets marimo manage propagation naturally.

A simple reactive pattern looks like this:

import marimo 

app = marimo.App() 

@app.cell 
def __(): 
 import marimo as mo 
 return mo, 

@app.cell 
def __(mo): 
 threshold = mo.ui.slider( 
 0, 100, value=50, label="threshold" 
 ) 
 return threshold, 

@app.cell 
def __(threshold): 
 current = threshold.value 
 return current, 

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

No extra state object is needed. The widget already owns the user-controlled value, and downstream cells react to it through ordinary symbol references.

By contrast, adding mo.state to mirror that same value creates another mutable path that readers must track. You now have to reason about synchronization, update order, and whether the mirrored state or the widget value is authoritative. That does not just add code; it adds causality that can become hard to debug.

When mo.state is justified

There are valid uses for mo.state, but they are exceptional. The documented cases tend to involve preserving historical UI-related information, synchronizing multiple UI elements, or introducing carefully controlled cycles. Those are advanced patterns because they intentionally step outside the default unidirectional dataflow that makes the notebook easy to reason about.

The API is:

state(value: T, 
 allow_self_loops: bool = False) 
-> tuple[State[T], Callable[[T], None]]

That signature alone should signal caution. You get a state holder and a setter, which means you are now managing mutable evolution explicitly rather than letting the graph derive values from upstream definitions. Use it when you genuinely need memory that should persist as part of the notebook’s behavior, not when you merely want to pass a value from one cell to another.

A reasonable example is preserving a short interaction history that cannot be represented as a pure function of the current widget value. Even then, treat it as an exception and keep the state boundary narrow and obvious.

External state still matters

A mature trust model distinguishes notebook-local state from environmental state. marimo can keep the notebook’s variables, outputs, and dependencies aligned, but external systems remain sources of change that the notebook cannot fully police.

Examples include:

files modified outside the notebook

database tables updated by other jobs

APIs that return time-varying results

clocks, randomness, and environment variables

long-lived service handles with their own internal caches

These are not failures of marimo’s model. They are reminders that reproducibility and state visibility extend beyond the notebook process. If a cell reads events.csv and the file changes on disk, the runtime can manage notebook-local dependencies correctly while the underlying input itself has changed. Likewise, if a deleted cell previously opened a network connection or mutated an external resource, scrubbing the local variable does not reverse the external effect.

The practical lesson is simple: trust notebook-local guarantees for what they cover, and model external inputs explicitly. Capture seeds, pin file paths intentionally, isolate service clients, and make ambient configuration visible where it enters the notebook. Those design practices matter even more when you formalize deterministic cell design.

Common failure patterns to audit aggressively

A few anti-patterns are worth treating as red flags.

Resurrected names after refactors. If you rename a core object but still find code behaving as if the old name exists, assume you have a lifecycle problem until proven otherwise. In marimo, deletion cleanup sharply reduces this risk, but sloppy refactors around external resources can still leave conceptual ghosts.

Initialize once and hope forever. A long-lived service object created early in a notebook can outlast the assumptions under which it was initialized. If credentials, endpoints, or configuration change, a cached client may still exist locally while no longer matching the source narrative.

Mutable singleton cells. A cell that creates a container and many later cells mutate it turns the notebook into shared-memory programming under a reactive façade. The graph remains visible, but semantic state becomes distributed across mutation sites.

Debugging by partial execution. Manually poking a late-stage cell to inspect one symptom can leave the live process in a state that is locally informative but globally incoherent. marimo reduces the hazard by driving execution through dependencies, but you should still treat partial interventions cautiously when they involve external effects or mutable objects.

A notebook audit procedure

When you need to evaluate whether a notebook is carrying unjustified state, run a disciplined audit.

1.

Pick an upstream definition that many cells consume.

2.

Rename or delete it deliberately.

3.

Observe whether downstream cells become invalid or stale in a way that matches your dependency expectations.

4.

Check whether any external artifacts still survive and can affect later behavior.

5.

Inspect cells for shared mutable objects and unnecessary mo.state usage.

6.

Classify remaining risks as notebook-local or environmental.

If the notebook-local part behaves cleanly under deletion and refactoring, you have strong evidence that code, outputs, and in-memory bindings are aligned. If surprises remain, they usually trace to one of two causes: mutation that obscures lineage or external systems whose state was never modeled explicitly.

That discipline changes how you read notebook outputs. A value is not trustworthy merely because it exists on screen or because it was computed recently. It is trustworthy when its presence can be justified by current source, visible dependencies, and a live state that contains no survivors from code you have already erased.

3.4 Designing Cells for Deterministic Behavior

A notebook can have correct dependency inference and still behave unpredictably if its cells hide inputs, mix pure computation with effects, or coordinate through mutable shared objects. Deterministic behavior does not mean every result is eternally identical; many analytical workflows depend on files, clocks, random seeds, user input, or external services. The practical target is narrower and more useful: when a result changes, you should be able to explain why from the notebook’s source, dependencies, and explicit inputs. Good cell design is what makes that possible.

Start with a small refactor that separates pure computation from effects. Save the following as deterministic_cells.py:

import marimo 

app = marimo.App() 

@app.cell 
def __(): 
 import marimo as mo 
 import pandas as pd 
 return mo, pd 

@app.cell 
def __(mo): 
 path = "events.csv" 
 min_count = mo.ui.slider( 
 1, 50, value=10, label="min count" 
 ) 
 return min_count, path 

@app.cell 
def __(pd, path): 
 raw_df = pd.read_csv(path) 
 return raw_df, 

@app.cell 
def __(raw_df): 
 counts = raw_df.groupby("kind").size() 
 counts = counts.reset_index(name="n") 
 return counts, 

@app.cell 
def __(counts, min_count): 
 filtered = counts[counts["n"] >= min_count.value] 
 return filtered, 

@app.cell 
def __(filtered): 
 preview = filtered.sort_values("n", ascending=False) 
 return preview, 

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

Open it with:

marimo edit deterministic_cells.py

This notebook does not yet do anything fancy, but it already illustrates four design choices that scale well. The file path is explicit. Data acquisition lives in its own cell. Transformation stages produce new named results rather than mutating upstream objects in place. The slider affects only the filtering boundary, not the raw load or grouping steps. If a result becomes surprising, you can inspect the graph and explain the outcome without reconstructing hidden execution history.

Treat each cell as a contract

The most reliable way to design deterministic cells is to think of each one as a contract with explicit inputs and explicit outputs. marimo’s @app.cell model supports exactly that interpretation, and the Cell.run(**refs: Any) API makes the idea concrete: a cell consumes referenced values and produces definitions. Even if you never instantiate Cell manually, that interface is the right mental model.

A good contract answers three questions clearly:

What names does the cell require?

What names does the cell define?

What side effects, if any, does it perform?

If you cannot answer those questions quickly, the cell is usually doing too much. Determinism suffers because readers cannot infer whether a changed result came from an upstream value, a hidden environmental input, a side effect, or an internal mutation that escaped the graph.

This is why the structural rules matter so much. One global definition per name across the notebook is not a cosmetic preference. It ensures that every consumer has one authoritative producer. Keeping dependencies acyclic is equally fundamental. Circular dependencies are breaking and cannot be fixed automatically because they destroy any valid execution order. Together, unique definitions and acyclic flow give you a graph that can be executed, inspected, and trusted.

Separate definition from invocation

One of the most effective notebook patterns is to separate reusable logic from the specific call that applies it. When you combine both in one volatile cell, every small experiment changes the producer boundary. When you isolate helper definitions,

you get a stable interface that downstream cells can depend on without churn.

A coarse, unstable design often looks like this:

@app.cell 
def __(raw_df): 
 def summarize(df, min_count): 
  counts = df.groupby("kind").size() 
  counts = counts.reset_index(name="n") 
  return counts[counts["n"] >= min_count] 

 summary = summarize(raw_df, 10) 
 return summary,

This cell defines a helper and immediately invokes it with an embedded parameter. Any edit to the parameter or helper logic changes the whole cell. A cleaner split is:

@app.cell 
def __(): 
 def summarize(df, min_count): 
  counts = df.groupby("kind").size() 
  counts = counts.reset_index(name="n") 
  return counts[counts["n"] >= min_count] 
 return summarize, 

@app.cell 
def __(raw_df, summarize): 
 summary = summarize(raw_df, 10) 
 return summary,

Now the helper is a stable producer, and the invocation is a separate, more volatile consumer. That split improves refactorability and makes the graph communicate intent. It also gives you a cleaner path to extract the helper into a module later or test it directly outside the notebook workflow.

Isolate side effects aggressively

Deterministic notebooks do not avoid side effects entirely; they isolate them. Reading a file, issuing an API call, writing a report, sending telemetry, storing a model artifact, or updating a database are all legitimate actions. The problem starts when you bury those effects inside cells that otherwise look like pure transforms.

A cell that both computes features and writes them to disk has two unrelated roles: deriving a value and committing an external effect. That mix makes reruns harder to reason about because reactive execution can now repeat the effect when all you wanted was to inspect the computation.

Prefer a split like this conceptually:

one pure cell computes the value to be written;

one separate cell handles the write or publication step.

The first cell can participate freely in reactive recomputation. The second cell becomes an explicit operational boundary. That distinction matters for cost control, but it matters even more for reproducibility. When you audit an output, you want to know whether rerunning a cell only recomputes a dataframe or also mutates the outside world.

This pattern also improves failure handling. If a write step fails, you can often inspect the pure computed value independently. If the computation and effect are fused, you lose that separation and debugging becomes tangled.

Make ambient inputs explicit

Many notebook surprises come from values that were technically available to the code but never declared as first-class inputs. Common examples include:

current time

random seeds

working directory assumptions

environment variables

implicit file paths

database connection configuration

If a cell depends on any of these, expose them at a clear ingress point. A path cell, configuration cell, parameter cell, or seed cell makes the dependency visible and gives the graph something concrete to encode around.

For example, do not scatter "events.csv" across multiple cells. Define the path once in a dedicated producer cell. Do not call a random generator in several places with implicit global state if reproducibility matters. Define a seed or random source explicitly and use it consistently. Do not let three different cells read environment variables independently if they are all conceptually using the same runtime configuration.

The goal is not ceremony. It is causal transparency. When an output changes, explicit ingress points tell you whether the source of change was code, data, configuration, or user interaction.

Prefer unidirectional dataflow

Unidirectional dataflow is the design habit that keeps reactive notebooks understandable under change. Values should generally move from stable producers toward downstream consumers in one direction, with each cell deriving outputs from explicit inputs. This looks almost obvious when stated that way, but many notebook anti-patterns are attempts to coordinate behavior by pushing data backward or sideways through mutable shared state.

The official guidance on state reflects that tension. The advanced mutable state API is

state(value: T, 
 allow_self_loops: bool = False) 
-> tuple[State[T], Callable[[T], None]]

That capability is real and sometimes useful, but it comes with strong warnings for good reason. Never mutate state directly; use the setter. Do not store marimo.ui elements in state. In most cases, you do not need mo.state at all. Widget values already participate in the reactive graph through their .value.

A simple UI-linked notebook should therefore prefer:

@app.cell 
def __(mo): 
 cutoff = mo.ui.slider( 
 0, 100, value=25, label="cutoff" 
 ) 
 return cutoff, 

@app.cell 
def __(cutoff, df): 
 filtered = df[df["score"] >= cutoff.value] 
 return filtered,

not a custom state object that mirrors the slider value. The second pattern creates another mutable coordination path, which makes causality harder to inspect and increases the risk of subtle bugs.

Use mo.state only for genuinely stateful behavior

There are rare cases where notebook behavior requires memory that is not a pure function of current inputs. One example is preserving a short interaction history. Another is synchronizing multiple controls whose behavior cannot be expressed cleanly through ordinary reactive references alone. Those cases are exceptional precisely because they increase complexity.

If you enable allow_self_loops=True, a setter can rerun the caller cell when that same cell also references the getter. That is powerful, but it changes the causal model from straightforward acyclic derivation to explicit feedback. Use it only when you can explain the update path clearly and when simpler decomposition will not do the job.

Even in valid uses, keep the state narrow:

store plain data, not UI objects

update through the setter only

keep the state’s consumers obvious

avoid using it as a generic cross-cell message bus

If a notebook begins to rely on several interacting custom state holders, step back and reconsider the design. What feels like convenience during rapid prototyping often becomes the source of the hardest debugging sessions later.

Design for extraction and testing

Deterministic cell design pays off beyond notebook execution. Cells that behave like explicit input/output units are easier to extract into modules, easier to test, and easier to review. A helper function cell with no hidden inputs can move into a library with minimal change. A transformation cell that depends only on a dataframe and a small parameter set is easier to validate than one that also reads files, checks environment variables, and mutates shared state.

This is another place where the Cell.run(**refs: Any) idea is useful even if you treat it purely conceptually. A well-designed cell can be described as “run this logic with these references and inspect these returned definitions.” That is close to the way you think about a testable unit in ordinary Python. The closer your notebook cells are to that model, the less notebook-specific mystery they contain.

The detailed mechanics of CI and external test harnesses belong elsewhere, but the design principle belongs here: make each cell’s behavior explainable from explicit inputs, and extraction becomes a straightforward engineering task rather than a refactor from a live interactive tangle.

Trade-offs in cell granularity

More cells are not always better. Over-decomposition can create navigation overhead, make the notebook visually fragmented, and force readers to chase tiny definitions across too many nodes. The right question is not “How small can I make each cell?” but “What is the narrowest boundary that still preserves semantic cohesion?”

Use these signals to decide when to split a cell:

The cell mixes a pure transform with an external effect.

Different lines in the cell change for different reasons.

A high-frequency parameter edit forces a costly downstream path.

The cell exports several unrelated names consumed by different branches.

You cannot describe the cell’s responsibility in one short sentence.

Use these signals to keep code together:

The lines form one conceptual transformation.

The outputs are always consumed together.

Splitting would create bookkeeping without reducing blast radius.

The cell is cheap and semantically cohesive.

The point is not aesthetic balance. It is to choose granularity that makes reruns predictable, side effects isolated, and lineage understandable.

A design review checklist

When you review a notebook for deterministic behavior, walk through it with a short checklist.

1.

Verify that each global name has exactly one defining cell.

2.

Check that dependencies remain acyclic and unidirectional.

3.

Mark cells as pure, effectful, or mixed; split the mixed ones.

4.

Identify ambient inputs such as paths, seeds, and environment values; move them to explicit ingress cells.

5.

Replace in-place mutation with new derived objects where practical.

6.

Remove custom mutable state unless the behavior truly requires memory beyond reactive values.

7.

Ensure widgets are consumed through .value rather than mirrored through unnecessary state.

8.

Confirm that expensive or effectful operations sit behind intentional boundaries rather than every interactive tweak.

Apply that checklist to a representative notebook and you can usually predict how it will behave from a clean run, under iterative editing, and during refactoring. The notebook stops being a pile of executable fragments and becomes a small reactive program with inspectable boundaries. That is the point where debugging gets easier, reruns become unsurprising, and interactivity remains useful without turning the runtime into an accumulation of guesses.

Chapter 4
Engineering with Pure-Python Notebooks

Experienced teams rarely reject notebooks because of computation alone; they reject them because review breaks down, reuse is ad hoc, and automation becomes fragile. This chapter shows how marimo’s pure-Python format changes that equation, then follows the consequences through editing, execution, modularization, and CI, with each section owning a distinct engineering problem rather than repeating generic "best practices."

4.1 Designing Git-Friendly Review Workflows for Notebook Code

A notebook can be analytically correct and still be a poor engineering artifact. Teams feel that failure first in Git: a pull request shows hundreds of changed lines for a one-line formula edit, merge conflicts land in serialization noise instead of executable code, and reviewers cannot tell whether a commit changed logic, output state, or editor-generated metadata. If your repository cannot represent notebook changes as ordinary, inspectable text, every later discipline—refactoring, testing, ownership, release management—starts from a weaker base.

marimo changes that boundary by storing the notebook itself as a Python file. That sounds like a formatting detail, but it changes the operational unit that Git sees. Instead of a JSON container with code, outputs, and notebook metadata interleaved, you review a .py artifact that participates in the same line-oriented workflow as the rest of your codebase. Diffs become statement-level. Blame becomes meaningful. Repository policies such as code owners, formatting hooks, and branch protections apply without notebook-specific adapters.

A simple workflow makes the difference concrete:

marimo check notebooks/sales_report.py 
git diff -- notebooks/sales_report.py 
python notebooks/sales_report.py

The first command validates notebook structure using notebook-aware checks before review or execution. The second shows a normal textual diff. The third runs the notebook through the standard Python entry point. None of these steps require converting the notebook into another format for automation, and none ask the reviewer to inspect a JSON payload to understand a code change.

Why JSON notebooks create review friction

The difficulty with conventional notebook review is not that JSON is invalid or unsupported. JSON is a reasonable container format for a rich interactive document. The problem is that version control systems work best on stable, line-oriented text where semantically local edits produce syntactically local diffs. Notebook JSON does not consistently provide that property. A small change in one cell can shift formatting or metadata over a much larger region of the file. Outputs can

dominate the patch. Execution counts can churn independently of logic. Even when specialized tools render notebook diffs well, the repository still stores a machine-oriented structure that ordinary Git commands do not interpret gracefully.

That mismatch shows up in four recurring failure modes.

Output-dominated diffs

A notebook that stores generated tables, plots, or large text outputs can bury the code edit that produced them. Reviewers end up paging through changed output blobs before they reach the line of analysis code that mattered. Even if your hosting platform collapses rich outputs, the raw patch remains noisy and merge-prone.

Metadata churn

Minor notebook interactions can modify cell identifiers, display metadata, execution order, and other incidental fields. These changes are not always harmful, but they rarely deserve review attention. Once metadata churn becomes normal, reviewers learn to skim aggressively, and genuine logic changes become easier to miss.

Opaque merges

JSON merge conflicts are often mechanically solvable but semantically unclear. Git can show you two competing object fragments; it cannot tell you which analytical intent each fragment represented. A reviewer must reconstruct notebook structure mentally before deciding whether the merged result is even executable.

Unhelpful history

Blame and file history lose value when every save rewrites large serialized regions. You want to ask, “Who changed the aggregation window from 30 days to 14?” You do not want the answer obscured by a commit that also rewrote output state and editor metadata across dozens of lines.

These are storage and collaboration problems, not notebook-computing problems. A team can have excellent analytical practice and still suffer poor review quality if the artifact presented to Git is unstable.

What changes when the notebook is a .py file

marimo stores notebooks as Python source. Cells are represented in code, and the file remains legible to standard text tooling. That does not magically make every patch small or every review easy, but it removes a large class of artificial noise.

The first gain is diff locality. If you change a threshold constant, add a filter, or rename a variable, the patch typically lands where the code lives. Git can show the edit as a normal hunk. Review comments attach to Python statements rather than serialized notebook internals.

The second gain is merge ergonomics. You still need disciplined branching and sensible commit size, but merge conflicts now occur in source text that a Python-literate reviewer can read directly. When two authors edit adjacent logic, the conflict is at least framed in the language of the program rather than the language of the container.

The third gain is ordinary repository integration. Code owners, protected

branches, pre-commit hooks, formatter checks, linter runs, and path-based CI triggers all work against the notebook file in the same way they work against modules and scripts. You do not need a second-class policy surface just because the artifact started life as a notebook.

The fourth gain is usable history. Because the notebook is a text artifact, git blame, search, and cross-reference tools become worth using. If a notebook participates in a long-lived analytical workflow, that historical visibility matters. It lets you trace changes in assumptions and business logic with the same forensic tools you already use for package code.

A realistic reviewable notebook artifact

A marimo notebook is not “just a script” in the conceptual sense. It still has notebook semantics and reactive structure. For Git workflows, though, the important fact is that the file is text-native and Python-native. You can validate it with marimo-aware tooling before a reviewer sees it.

marimo check notebooks/pricing_analysis.py

When the notebook is structurally unsound, marimo check catches problems that generic Python tools do not express in notebook terms, including issues such as cycle dependencies or invalid cell structure. That makes it an effective pre-review gate. A patch that cannot pass notebook integrity checks should not reach human review, because the reviewer would otherwise spend time discussing code that the notebook runtime cannot interpret correctly.

For repository-wide validation, the same command scales cleanly:

marimo check .

If you want automated cleanup where supported, marimo also provides a fix mode:

marimo check --fix .

That combination suggests a practical rule: fail fast on notebook integrity before asking reviewers to reason about analytical intent.

Review discipline starts with commit shape

Once notebooks are stored as Python, teams often assume the review problem is solved. It is not. You have improved the artifact, not the authoring discipline. The next failure mode is self-inflicted: giant monolithic commits that mix exploratory edits, cell reorganization, formatting churn, and genuine logic changes.

Treat notebook commits as review units with explicit intent. Three categories should stay separable whenever possible.

Structural edits

These are changes to cell boundaries, ordering, setup imports, or top-level organization. They may be necessary to make later logic cleaner, but they are hard to review when mixed with semantic changes. A reviewer should be able to answer, “Did

the notebook structure change?” independently from, “Did the computation change?”

Semantic edits

These modify data transformations, calculations, query predicates, model parameters, or business rules. These are the changes that deserve the reviewer’s deepest attention. Keep them isolated enough that a reviewer can reason about correctness without also parsing unrelated file churn.

Formatting-only edits

If you run a formatter, import sorter, or wide rename across the notebook, commit that separately. Formatting churn is acceptable when isolated and harmful when mixed with semantics. A reviewer should never have to distinguish a line-wrap change from an altered expression in the same noisy patch.

This discipline matters more in notebooks than in conventional modules because notebooks often mix orchestration, presentation, and computation. When a pull request contains all three kinds of change at once, reviewers are forced to reconstruct intent from patch shape. Most will not do that consistently.

Repository policy for notebook paths

A team-scale repository needs notebook-specific hygiene even when notebooks are plain Python. The policy can stay simple.

First, keep notebooks in predictable paths such as notebooks/ or a domain-specific subdirectory. That lets you attach code owners, CI rules, and review expectations by path. A notebook buried arbitrarily inside a source tree becomes harder to discover and easier to exempt accidentally from policy.

Second, decide whether notebooks are production-adjacent assets, exploratory assets, or both. If both exist in one repository, encode the difference in directory layout. For example, you might reserve one path for durable analytical assets that must pass validation and review, and another for personal or temporary exploration that is excluded from branch protections. The directory boundary is not just tidiness; it is an enforcement surface.

Third, keep generated artifacts out of version control unless they are deliberate release outputs. A Python-backed notebook becomes reviewable partly because the repository stops tracking machine-generated notebook state. Do not reintroduce noise by checking in sidecar HTML exports, ad hoc CSV dumps, screenshots, or cached data snapshots next to the notebook unless the repository policy treats those files as explicit deliverables.

A concise .gitignore usually carries that policy:

# Notebook exports and local artifacts 
*.html 
*.ipynb 
artifacts/ 
.cache/

The exact ignore list depends on your workflow, but the principle is stable: review source, not ephemeral byproducts.

Pull requests should present notebook changes as code changes

A well-shaped notebook pull request reads like a normal software change. The notebook file is one artifact among others, not a special opaque object that reviewers

must inspect in a separate mental mode.

That usually means presenting notebook edits alongside the modules, SQL files, configuration, or tests they depend on. If a notebook change introduces a reusable helper that is already serving more than one analytical path, place the helper in package code and review it there. Keep the notebook focused on orchestration, analysis flow, and presentation-specific logic.

A useful reviewer heuristic is simple: if understanding the notebook patch requires holding more than a few dozen lines of reusable logic in working memory, request extraction. The issue is not purity or architectural elegance. It is reviewability. Reusable logic hidden deep inside a notebook imposes high cognitive load on every future patch.

When to keep logic notebook-local

Not every block of nontrivial code should be extracted. Notebook-local code remains appropriate when it is tightly bound to the analysis narrative, used once, and clearer inline than behind a named abstraction. Examples include one-off reshaping steps, display-oriented transformations, and analysis scaffolding that would be harder to understand if split into a helper module prematurely.

The decision signal is not line count alone. Ask whether extraction would improve review by creating a stable, testable unit with a clear name and future reuse potential. If the answer is no, leaving the code in the notebook can be the more disciplined choice. A notebook that becomes nothing but thin wrappers around obscure helper modules may be mechanically tidy and intellectually harder to review.

Blame, ownership, and audit trails

Once the notebook is plain Python, ownership rules become practical. You can assign code owners by directory, require review from domain experts, and rely on standard blame history to answer operational questions. That matters for analytical code because business logic often evolves through small threshold changes, query adjustments, and cleanup steps that are easy to lose in informal workflows.

Text-native history also improves incident response. If a report changes unexpectedly, you can inspect the relevant notebook patch the same way you would inspect a service regression. That is a substantial operational improvement over notebook histories dominated by metadata churn or output snapshots.

Anti-patterns that survive the file-format improvement

Pure-Python storage removes many obstacles, but several bad habits remain common.

Monolithic review units

A 1,000-line notebook patch is still a bad review, even when every line is valid Python. The artifact is cleaner, but the cognitive burden remains too high.

Mixed exploratory and production edits

Do not combine speculative experimentation with production-bound analytical changes in one pull request. Reviewers cannot tell which parts are durable and which

are transient, and revert decisions become messy.

Custom side-channel state

Some teams stop storing notebook outputs in the main file and then start committing generated state somewhere else: screenshots, temp exports, cached datasets, or hidden config fragments. That recreates the same review noise under a different filename.

Formatter churn as semantic camouflage

Because notebooks are now text, it becomes easy to run broad auto-formatting. That is useful, but if you mix a formatter sweep with behavioral edits, you have simply replaced notebook metadata noise with style noise.

Reviewer reliance on mental execution

A textual diff is easier to read, but reactive notebooks still have execution consequences that may not be obvious from local code changes alone. Reviewers should not be asked to infer the entire downstream effect of a nontrivial dependency change purely from the patch. Validation and execution checks belong in automation, not in reviewer intuition.

Minimal baseline policy

A durable team workflow usually fits into a short checklist:

Store durable notebooks as .py files in a predictable path.

Run marimo check before review and in CI.

Separate structural, semantic, and formatting edits.

Exclude generated artifacts unless they are explicit deliverables.

Extract notebook-local code when reuse or reviewability clearly improves.

Require the same ownership and branch protections used for modules.

That policy is intentionally modest. It does not require a notebook-specific platform or a special review ritual. It aligns notebook artifacts with the repository mechanics you already trust for Python code.

marimo makes that alignment feasible because the notebook itself stops fighting the version control system. After that, the quality of collaboration depends on whether you preserve clean diffs, small review units, and explicit repository boundaries with the same care you expect everywhere else in the codebase.

4.2 Editing Notebook Files in IDEs and Refactoring Across Repositories

A notebook stops being a serious maintenance liability when your editor can treat it as source code instead of a special artifact. The practical test is not whether you can open the file and type into it. The test is whether rename, go-to-definition, diagnostics, formatting, search, and project-wide refactoring continue to work when the code happens to live in a notebook. Teams usually discover the answer under pressure: a shared function changes name, a package gets reorganized, or a type-driven cleanup touches dozens of files. If notebooks fall out of the tooling surface at that point, the repository splits into two engineering classes of code.

marimo avoids that split by storing notebooks as Python files and serializing cells as functions decorated with @app.cell. That representation matters because ordinary Python parsers, language servers, and IDEs can understand the file as Python source. You do not need a notebook-aware editor for every day-to-day operation. You need a normal editor with a clear synchronization path to the notebook runtime.

A working setup is straightforward:

pip install "marimo[lsp]" 
marimo edit --watch notebooks/forecast.py

If you prefer uv, the install path is equally direct:

uv add "marimo[lsp]" 
marimo edit --watch notebooks/forecast.py

The first command installs marimo with Language Server Protocol support. The second starts the notebook editor in watch mode so file changes on disk stay synchronized with the browser session. If you are serving the notebook as an app rather than editing it interactively, use the corresponding watch mode:

marimo run --watch notebooks/forecast.py

That workflow addresses a practical tension. You want the browser-based notebook experience when it helps you reason about the reactive document, but you also want the precision of your preferred IDE for search, refactor, code actions, and diagnostics. Watch mode keeps those worlds aligned instead of forcing you to choose one.

Why the file representation matters to IDEs

Many notebook workflows claim editor compatibility in the loose sense that you can store a textual mirror beside a notebook. That is not the same as the notebook being the text artifact. Once the notebook itself is a Python file, the editor operates on the primary source of truth. Rename changes the notebook file directly. Search results refer to the notebook file directly. Static analysis reads the notebook file directly. There is no synchronization tax between a reviewable representation and an executable representation.

marimo’s cell serialization is central here. Cells are written as decorated Python functions, which gives the file a structure that Python-aware tools can parse. You get syntax highlighting, indentation behavior, brace matching, symbol indexing, and cross-file navigation because the file is valid Python source. You are no longer asking the editor to understand a JSON container or a secondary paired format.

That design also helps with a subtle but important tooling boundary: most editors operate on syntax trees and symbol tables, not on notebook UI concepts. A Python-backed notebook gives those tools enough structure to remain useful without requiring each IDE feature to be reimplemented in notebook-specific form.

Using watch mode without giving up your editor

When you edit marimo notebooks outside the browser, watch mode is the operational glue. Start the notebook once with marimo edit –watch, then work in VS Code, neovim, PyCharm, Cursor, or another editor against the .py file. marimo watches the file and updates the editing session. For running apps, marimo run –watch refreshes the served app when the file changes.

A typical terminal layout looks like this:

# Terminal 1: notebook editing session 
marimo edit --watch notebooks/forecast.py 

# Terminal 2: editor-integrated checks 
marimo check notebooks/forecast.py

If file watching feels sluggish or unreliable, install watchdog. marimo performs better with watchdog; otherwise it falls back to polling. That detail matters in larger repositories and on filesystems where polling latency is noticeable. Slow file detection is not just an annoyance. It erodes trust in the feedback loop and makes the editor feel disconnected from the notebook runtime.

What LSP support gives you in practice

marimo supports standard LSP features including completion, hover, go-to-definition, diagnostics, code actions, rename, and signature help. Those features change the economics of notebook maintenance because they reduce the gap between notebook edits and module edits.

Rename

A symbol-aware rename is the first serious test of repository integration. If you rename a data loader, a domain model, or a utility function, you want the IDE to update references in notebooks and ordinary modules consistently. Text search can approximate that, but it does not distinguish symbol uses from comments, strings, or

unrelated identifiers. LSP-backed rename does.

Go-to-definition

Analytical code often depends on a mix of local transformations and imported package logic. Go-to-definition lets you move from notebook usage to module implementation without mentally tracking file boundaries. That keeps the notebook readable because you do not need to inline every helper just to preserve discoverability.

Diagnostics and code actions

Diagnostics catch unresolved imports, type mismatches where your toolchain can infer them, and obvious syntax issues before you run the notebook. Code actions help with import organization and quick fixes. They are not notebook-specific features, but using them on notebook files is exactly what makes a notebook repository feel coherent rather than bifurcated.

Signature help and autocompletion

Type hints propagate into serialized cell signatures, which improves autocompletion across cell references. That feature matters more than it first appears. In many notebook systems, moving data from one cell to another weakens tooling because the connection lives primarily in runtime state. When cell interfaces remain visible in Python signatures, completion and hinting stay useful across notebook boundaries.

Choosing and combining language servers

marimo documents support for multiple language servers. That lets you tune your editing experience instead of accepting a one-size-fits-all setup. For example, you may want one server for rich completions and navigation, while basedpyright contributes diagnostics inside marimo. The practical implication is that notebook files can participate in the same editor policy you use elsewhere in the repository: one team may prefer a lightweight server for responsiveness, another may value stricter analysis.

You do not need to overengineer the setup early. Start with a single well-supported Python language server and confirm that rename, definition lookup, and diagnostics work reliably in notebooks and modules together. Add a second server only when it closes a real gap, such as stronger diagnostics.

Refactoring across notebooks and packages

Once notebook files are normal Python artifacts, repository-wide refactoring becomes feasible. Feasible is not the same as risk-free. You still need to account for notebook structure, especially when the notebook is a readable analytical composition rather than a general-purpose module.

Consider a common progression. You begin with a notebook-local helper used in several cells. The helper stabilizes, then a second notebook copies it. At that point, you want a named, importable definition that IDEs and refactoring tools can target directly. marimo supports top-level reusable definitions with @app.function and @app.class_definition. That gives the editor a clear symbol boundary and gives the repository a real import surface.

A minimal example looks like this:

import marimo 

app = marimo.App() 

@app.function 
def normalize_region(name: str) -> str: 
    return name.strip().lower().replace("-", " ") 

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

Elsewhere, you can import that symbol with ordinary Python import syntax:

from notebooks.forecast import normalize_region

That is the key inflection point for refactoring. Once the helper has a real symbol identity, repository-wide rename and go-to-definition can treat it like any other Python definition. You stop copying code between notebooks and start moving through explicit imports.

Refactor in layers, not in one leap

Large notebook-heavy repositories benefit from staged refactors. The safest order usually looks like this:

Rename local variables and small helpers within a notebook.

Promote stable helpers to @app.function or @app.class_definition when you need importable symbols.

Update imports across notebooks and package modules.

Run notebook-aware validation after the structural edits.

That order keeps the diff intelligible and limits failure scope. If you jump directly to package reorganization, broad renames, and notebook cleanup in one patch, you make it hard to isolate whether a break came from import movement, reactive structure changes, or a simple symbol mismatch.

Reactive structure still matters

Plain-Python storage does not erase the conceptual role of cells. An aggressive refactor can be syntactically correct and still make the notebook harder to understand. Editors will happily inline helpers, collapse logical stages into dense abstractions, or reorganize imports mechanically. The question is whether those edits preserve the notebook’s dependency intent and reading order.

Treat each cell as both an execution unit and a communication unit. When you refactor, preserve boundaries that help a reader understand data flow, assumptions,

and transformation stages. Do not merge conceptually distinct operations just because a formatter or extract-method suggestion says you can. A notebook remains a composed analytical document, not merely a Python file with unusual comments.

Formatters and linters: use them, but isolate their churn

Because marimo notebooks are Python files, standard formatters and linters become available immediately. That is a major operational win, but it introduces a new kind of noise if you apply them carelessly. A repository-wide format change across notebooks can be perfectly healthy; mixing that change with semantic edits is not.

Use a simple rule: run formatters consistently, commit their output separately, and avoid interpreting formatting success as semantic safety. Formatters improve readability and reduce style drift. They do not validate reactive dependencies, notebook structure, or analytical correctness. Pair them with marimo check so that notebook-specific integrity remains part of the feedback loop.

marimo check notebooks/

When that command fails after a broad edit, treat the failure as a structural signal, not as an inconvenience to bypass. Generic Python tools can tell you the file parses. marimo can tell you whether the notebook representation itself remains valid.

A monorepo rename scenario

Suppose a repository contains package code under src/analytics/ and several marimo notebooks under notebooks/. A utility named normalize_region moves to a better domain name, canonicalize_region. In a notebook-hostile environment, you would update package modules with IDE rename, then manually patch notebooks with search-and-replace and hope you caught every reference.

With marimo notebooks stored as Python, the rename can proceed through the editor’s symbol tools. The notebook participates because the file is parseable Python and the import surface is explicit. After the rename, run notebook integrity checks and any fast repository tests. The key gain is not just speed; it is confidence. You have fewer blind spots where notebook code escapes the normal refactoring surface.

When ordinary editor workflows stop being enough

Editor support is strong, but not unlimited. Static analysis cannot infer every runtime consequence of reactive execution. A rename may succeed syntactically while changing notebook readability or obscuring a data dependency that was clearer before. Similarly, a broad import cleanup may be legal while making the notebook’s top-level setup harder to scan.

That boundary suggests a practical discipline: trust editors for symbol-aware mechanics, but make human decisions about analytical structure. Ask two questions after a nontrivial refactor:

Does the notebook still read in a way that exposes its computational stages clearly?

Did the refactor improve reuse or consistency enough to justify the extra indirection?

If the answer to the first question is no, the refactor is too aggressive. If the answer to the second is no, the abstraction is probably premature.

Markdown-format notebooks and the trade-off surface

You may encounter markdown-oriented notebook formats that are pleasant to edit as prose, especially when narrative dominates. They can work well for text-first documents, but they trade away engineering capabilities that matter in software-bearing repositories. In marimo’s documented model, markdown notebooks cannot be imported as libraries, run as scripts, or use reactive tests. That limitation makes them a weaker fit when your notebook must participate fully in refactoring, automation, and repository-wide tooling.

Use the Python format when the notebook is part of a maintained codebase. Reserve text-first alternatives for documents whose primary value is narrative and whose operational role is intentionally limited.

Failure modes worth catching early

A few anti-patterns appear repeatedly once teams gain access to better tooling.

Treating the notebook exactly like a module

You can apply Python tooling to the notebook, but you should not flatten away notebook structure merely because the editor permits it. Preserve narrative staging and reactive readability.

Moving code too late

If two or three notebooks already carry the same helper logic, delay becomes expensive. Every later rename or bug fix multiplies across copies. Promote stable helpers into importable definitions before duplication hardens.

Auto-fixing before understanding

Code actions and formatter integrations can rewrite imports, signatures, and layout quickly. Use them after you understand the notebook’s structure, not as a first move in a fragile file.

Overconfidence in static tooling

Language servers are excellent at symbol mechanics. They do not substitute for execution checks, notebook-aware validation, or human review of dataflow intent.

When your editor can rename across notebook files, surface diagnostics in the notebook, and follow definitions into package code, notebook maintenance stops feeling exceptional. The file becomes part of the same working set as the rest of your Python repository, and refactoring decisions can focus on design quality instead of

tooling limitations.

4.3 Running Notebooks as Scripts in Automated Workflows

A notebook often starts as an interactive workspace and only later becomes operational infrastructure. The moment that happens is usually unglamorous: someone wants the report every morning, the data quality check must run in CI, or a release process needs a rendered artifact with stable inputs. If the notebook can only be driven by a human in an editing session, it remains a useful analysis surface but a poor automation surface. marimo changes that by letting you execute the notebook non-interactively as ordinary Python.

A minimal automation path is short enough to remember:

marimo check notebooks/report.py 
python notebooks/report.py --date 2026-06-10 
marimo export html notebooks/report.py \ 
  -o build/report.html -- --date 2026-06-10

That sequence encodes three distinct concerns. First, validate notebook integrity with marimo check. Second, run the notebook directly through Python for batch execution. Third, export a shareable HTML artifact, passing notebook arguments after –. The separation matters. Validation catches notebook-structure problems before the scheduler burns time on a broken run. Direct Python execution gives you the simplest non-interactive entry point. Export turns the notebook into a build artifact without forcing the automation job to serve an app.

Script execution is not app serving

marimo supports multiple operational modes, and the distinction is important. For direct non-interactive execution, use ordinary Python:

python notebooks/report.py

For serving the notebook as an app, use marimo’s run command:

marimo run notebooks/report.py

Those commands solve different problems. python notebook.py is the script path. It fits cron jobs, CI steps, one-shot report generation, and scheduler invocations. marimo run notebook.py is the app path. It serves the notebook in an interactive application mode. Confusing them leads to awkward automation designs, such as launching a server process in a job that only needed a deterministic batch run.

You should decide between them based on control flow. If the job has explicit inputs, a bounded runtime, and outputs that can be written to disk or emitted to logs, script execution is usually the right abstraction. If a human needs to manipulate controls at runtime, inspect intermediate states interactively, or use the notebook as a shared UI, app serving is the better

match.

Validation belongs before execution

A pure-Python notebook can enter ordinary automation, but notebook-aware validation should still happen first. The correct quality gate is:

marimo check notebooks/report.py

That command catches notebook-specific integrity failures that the Python interpreter alone does not express in notebook terms. In automation, this is more than a nicety. A scheduler failure at minute zero should tell you that the notebook structure is invalid, not leave you deducing from a later runtime exception that the file had a dependency-cycle issue or malformed notebook state.

For repository-wide jobs, validate all notebooks in one pass:

marimo check .

This scales well in CI, especially as a fast first stage before any expensive data access or report rendering.

Parameterization: keep inputs explicit

Once a notebook runs as a script, the immediate temptation is to keep changing variables in editable cells and treat those as parameters. That approach breaks automation discipline almost instantly. A scheduler needs explicit, externalized inputs. A human operator needs a reproducible command. A failed run needs enough context to be replayed.

marimo notebooks executed as scripts can handle parameters with standard Python mechanisms, including sys.argv, argparse, or simple-parsing. In practice, argparse gives the best baseline because it makes parameters self-describing and helps you enforce defaults, required options, and type conversion.

A minimal pattern looks like this:

import argparse 
import marimo 

app = marimo.App() 

parser = argparse.ArgumentParser() 
parser.add_argument("--date", required=True) 
args = parser.parse_args() 

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

The snippet is intentionally small. The point is not to turn the notebook into a command-line framework. The point is to make operational inputs visible and replayable. If a nightly job ran with –date 2026-06-10, you should be able to reproduce that locally with the same command line.

Choosing a parameter source

Different automation contexts favor different input mechanisms.

Command-line arguments

Use command-line arguments when the run should be auditable and easy to replay. They work well for report dates, partition identifiers, output locations, and mode switches such as –dry-run. They are the best default for CI jobs and schedulers because the invocation is visible in the job definition.

Environment variables

Use environment variables when you need secrets or deployment-specific settings such as API tokens, warehouse hosts, or bucket names. Avoid using them for ordinary business parameters that deserve explicit logging and replayability. An environment variable is easy to inject and easy to forget.

Configuration files

Use configuration files when the notebook has a richer settings surface than a few flags can express cleanly. Keep the config path explicit on the command line so the run remains reproducible. Avoid hidden local defaults that differ across developers and runners.

Imported settings objects

Use imported settings only when the notebook belongs tightly to a package or application with an established configuration system. This can be the right long-term design, but it increases coupling. If the notebook needs to remain a standalone automation artifact, prefer direct arguments or a clearly named config file.

A useful rule is simple: if an operator cannot reconstruct the runtime inputs from the job definition and logs, the notebook is under-parameterized or parameterized in the wrong place.

Outputs should be deliberate artifacts

Automation does not care that a notebook was pleasant to inspect interactively. It cares that the run produced something clear: logs, files, and exit status. Decide up front what the notebook emits.

Common output classes are:

Console logs for schedulers and CI.

Rendered HTML for human consumption.

Data files such as CSV, Parquet, or JSON.

Side effects in databases or object stores.

Treat each output class as part of the notebook’s contract. If the notebook generates a nightly report, define the output path and naming convention explicitly. If it writes a validation dataset, make the destination configurable. If it performs side effects, log enough context to audit what happened.

For shareable rendering, marimo supports direct export:

marimo export html notebooks/report.py \ 
  -o build/report.html

When the notebook itself expects arguments, pass them after –:

marimo export html notebooks/report.py \ 
  -o build/report.html -- --date 2026-06-10

That command structure is operationally useful in build pipelines. You can execute the notebook logic and generate a durable artifact in one reproducible step.

Standalone reproducibility with sandbox mode

Some notebooks need to travel independently of a project environment. An example notebook, a one-off analysis artifact, or a self-contained scheduled job may not belong to a larger package with a single shared pyproject.toml. For those cases, marimo supports sandbox mode backed by PEP 723 inline script metadata.

PEP 723 defines an inline metadata block for scripts, including keys such as requires-python and dependencies. In practice, this means the notebook file can carry its own dependency declaration. That reduces the gap between repository artifact and execution environment.

A compact header looks like this:

# /// script 
# requires-python = ">=3.11" 
# dependencies = [ 
# "marimo", 
# "pandas", 
# "matplotlib", 
# ] 
# ///

With inline metadata present, you can launch the notebook in a managed sandbox:

marimo run --sandbox notebooks/example.py

You can also execute it with uv:

uv run notebooks/example.py

This pattern is attractive when you need a notebook to be self-describing and portable. It is less attractive when the notebook belongs to an application or package that already has centralized dependency management. In that case, duplicating environment declarations inside notebook files can create drift.

CI and scheduled jobs: keep the workflow boring

A good automation design is usually boring: predictable Python setup, explicit commands, stable paths, and fast failure. In GitHub Actions, use actions/setup-python to establish a consistent Python runtime across runners. Then install dependencies, validate the notebook, run it, and collect artifacts.

A compact job looks like this:

name: nightly-report 

on: 
  schedule: 
    - cron: "0 2 * * *" 
  workflow_dispatch: 

jobs: 
  build-report: 
    runs-on: ubuntu-latest 
    steps: 
      - uses: actions/checkout@v4 

      - uses: actions/setup-python@v5 
        with: 
          python-version: "3.11" 

      - name: Install deps 
        run: pip install marimo pandas matplotlib 

      - name: Validate notebook 
        run: marimo check notebooks/report.py 

      - name: Run notebook 
        run: > 
          python notebooks/report.py 
          --date 2026-06-10 

      - name: Export HTML 
        run: > 
          marimo export html notebooks/report.py 
          -o build/report.html 
          -- --date 2026-06-10

The job is intentionally explicit. You can see setup, validation, execution, and artifact generation in order. If you prefer inline dependency metadata and uv, the same general flow applies, but the environment comes from the script rather than a project-level install step.

Failure handling and observability

Schedulers and CI systems need runs to fail clearly. That means two things: meaningful exit behavior and readable logs. A notebook author may tolerate an implicit local path, a silent empty result, or an unstructured traceback. Automation should not.

Expose operational checkpoints in logs: input parameters, data source identifiers, row counts where relevant, output targets, and completion markers. Keep those logs concise enough that a failed job can be diagnosed without opening the notebook in an editor. If a run performs expensive work, emit progress boundaries before and after the costly stages.

Also distinguish deterministic failure from transient infrastructure failure where you can. A missing required parameter, invalid date, or absent output directory is a design problem. A temporary network or credentials issue is an environmental problem. You may handle both with exceptions, but operators should not have to infer the category from ambiguous notebook output.

When script mode is the right abstraction

Script mode works best when the notebook remains the clearest executable specification of a bounded workflow. Common signals include:

the job has explicit inputs and explicit outputs;

a human does not need to manipulate controls at runtime;

the notebook is still the most readable orchestration surface;

the workflow fits into a single process invocation; and

operational state can be reconstructed from logs and arguments.

This is especially compelling for recurring reports, validation notebooks, deterministic data extracts, and model-analysis artifacts that benefit from notebook readability but do not require a long-lived service.

When to prefer a module or an app

Script mode is not a universal destination. Choose a reusable module when the primary value has shifted from notebook orchestration to reusable logic and composition from other code. Choose an app when interaction is the point: sliders, filters, on-demand exploration, or user-driven scenario analysis.

The decision often turns on who is in control at runtime. A scheduler prefers scripts. Another Python program prefers modules. A human operator prefers an app.

The matrix captures the trade-off cleanly: script mode is usually the most direct fit when you need explicit inputs, scheduler-friendly execution, and build artifacts without taking on the full abstraction cost of a package-first design or the interaction model of an app.

Anti-patterns that break automation quickly

A few patterns repeatedly turn notebook scripts into fragile jobs.

Editable-cell parameters

If the run date, dataset identifier, or output path lives in a code cell that someone edits by hand, the notebook is not automated. It is manually configured software with a scheduler attached.

Implicit local paths

Writing to ./output.csv may work on a laptop and fail in CI or a container. Make output paths explicit and ensure parent directories exist before writing.

Mixed interactive and batch assumptions

A batch run should not depend on someone clicking a widget or inspecting a transient UI state. If human interaction matters, serve an app instead.

Hidden environment dependence

A notebook that succeeds only because the author’s shell exports custom variables or the machine has untracked local files is not operationally reproducible.

Overloaded notebooks

If the notebook now orchestrates multiple branches, retries external systems, manages long-lived state, and coordinates downstream tasks, it is probably acting as a poor substitute for a workflow engine or service layer.

Used carefully, script mode preserves what makes notebooks valuable—readable, executable analytical composition—while giving automation the things it actually needs: explicit parameters, deterministic startup, clear artifacts, and a command line that can run unattended at 2 a.m.

4.4 Extracting Reusable Modules and Preserving Notebook Boundaries

Once a notebook becomes ordinary Python, the next engineering temptation is predictable: move everything reusable into packages immediately. That impulse fixes one problem and creates another. If you extract too little, notebooks accumulate copied helpers, divergent bug fixes, and awkward imports. If you extract too much, the notebook collapses into a thin wrapper around abstractions that hide the analytical story. The useful boundary is not “notebook versus module.” It is “orchestration and exploration versus stable logic with a clear reuse surface.”

A small example makes the boundary concrete. Suppose a notebook contains a feature-building helper that has stopped being experimental and now appears in two other notebooks. Promote it to a reusable top-level definition:

import marimo 
import pandas as pd 

app = marimo.App() 

@app.function 
def compute_features(df: pd.DataFrame) -> pd.DataFrame: 
    result = df.copy() 
    result["revenue_per_user"] = ( 
        result["revenue"] / result["users"] 
    ) 
    result["is_large"] = result["users"] >= 1000 
    return result 

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

Downstream code imports it with ordinary Python syntax:

from analysis_notebook import compute_features

That import surface is deliberately constrained. A reusable top-level marimo definition must define exactly one function or class and may only depend on setup-cell symbols or other top-level symbols. The decorators are exact: @app.function for a function and @app.class_definition for a class. Those rules are not mere marimo trivia. They force you to separate stable, importable logic from notebook-local state and reactive execution.

The first design question: what belongs where?

Treat notebook code as four categories rather than one undifferentiated blob.

Stable domain logic This is code whose meaning does not depend on the notebook’s presentation or interactive flow: feature engineering transforms, parsing logic, validation routines, scoring functions, deterministic query builders, and business rules. Stable domain logic usually wants one of two homes: a top-level reusable definition inside the notebook, or a package module if the logic has grown broader than notebook scope.

Notebook orchestration This is the glue that sequences the work: load data, choose parameters, invoke transforms, render results, and coordinate outputs. Orchestration is often best left in ordinary @app.cell cells because it expresses the notebook’s readable execution structure.

Presentation-specific transformations Some code only exists to make a table legible, prepare a plot, or adapt raw results to a notebook-specific narrative. It may be deterministic and nontrivial, but if its value is tightly tied to that notebook’s presentation, keeping it local can preserve clarity.

Volatile exploratory code This includes short-lived probes, ad hoc comparisons, debugging cells, and temporary diagnostics. You should treat this code as intentionally unstable and avoid elevating it into reusable interfaces prematurely.

That classification matters because many notebook maintenance failures come from confusing stability with complexity. A complex block is not automatically reusable. A short block is not automatically notebook-local. Reuse decisions hinge on volatility, audience, and ownership.

Why marimo’s reusable-definition rules are healthy constraints

marimo’s top-level reusable definitions are intentionally more constrained than ordinary free-form notebook cells. A reusable top-level definition may only depend on setup-cell symbols or other top-level symbols. The setup cell is the proper boundary for imports and constants needed by reusable definitions.

Those constraints buy you three important properties.

Importability with ordinary Python semantics When a function or class is

reusable, other notebooks and modules can import it with normal Python import syntax. That means the symbol participates in editor navigation, test discovery, and static tooling without a special notebook bridge.

Explicit dependency boundaries By limiting dependencies to setup-cell symbols and other top-level symbols, marimo prevents you from quietly coupling reusable logic to arbitrary reactive notebook state. A helper that depends on a widget value, hidden local variable, or notebook-specific transient object is not truly reusable, even if it looks convenient in the moment.

Reviewable evolution A top-level reusable definition has a sharper contract than an incidental helper inside a reactive cell. Reviewers can ask whether its inputs, outputs, name, and dependencies still make sense over time. That becomes difficult when logic remains buried inside notebook-local flow.

The setup cell is the real boundary

The setup cell deserves more design attention than it usually gets. It is not just a place to dump imports. In marimo’s reuse model, it is the stable dependency boundary for top-level definitions. If a reusable function needs pandas, a configuration constant, or a domain enum, place those dependencies where the top-level definition can legally and predictably reference them.

This has a practical consequence: resist the urge to hide reusable-definition dependencies inside arbitrary notebook cells. Doing so may work locally for a while, but it weakens importability and blurs the contract. If you expect a helper to survive beyond one notebook, make its dependency surface obvious at the top of the file or move it into package code.

When @app.function is enough

Not every extracted helper should become a package module. @app.function is a good fit when the logic is reusable, but the notebook still owns the abstraction. Typical examples include:

a feature transform shared by two or three notebooks in one analytical area;

a small parsing or validation routine that belongs naturally with the notebook’s domain context;

a helper you want to import into tests before deciding whether it deserves package status.

This middle ground is valuable. It gives you a clean symbol boundary without forcing a full package extraction before the abstraction has proven itself. In other words, @app.function is not a compromise for code that is “not real enough” for a module. It is a way to keep reuse close to the notebook while still enforcing explicit design rules.

When to move beyond notebook-level reuse

Some code should leave the notebook entirely. The strongest signals are operational rather than aesthetic.

Multiple ownership domains If the logic serves notebooks, services, tests, and batch jobs across different directories or teams, package it. Shared ownership is easier to manage when the code sits in a conventional module with explicit API boundaries.

Independent testing needs When a function or class deserves extensive unit tests, type-focused checks, or benchmark coverage independent of notebook assembly, it usually wants module status. You can still import it back into notebooks, but its center of gravity has moved.

Growing dependency surfaces If a reusable notebook definition starts depending on several other helpers, configuration types, file-system logic, or external integrations, you are approaching module territory. Top-level notebook reuse works best for focused abstractions, not for building a mini-library inside a notebook.

Public API pressure Once others begin treating the function or class as an interface they rely on, package semantics become easier to manage than notebook-local evolution. This includes versioning expectations, deprecation plans, and clearer ownership.

Classes: use @app.class_definition sparingly

Classes are legitimate reusable boundaries, but notebooks tend to encourage them too early as a way to bundle state that should stay explicit. Use @app.class_definition when a class genuinely clarifies the domain: a parser with a stable configuration model, a domain object with meaningful invariants, or a stateful abstraction that reduces repetition across multiple consumers.

Avoid classes that merely hide notebook-local procedural flow. If the object exists only to shuttle temporary values between analytical steps, the notebook is probably clearer with functions and explicit cell outputs. Classes become worthwhile when they simplify reasoning for importers, not when they conceal notebook clutter.

Project notebooks versus sandbox notebooks

marimo distinguishes between sandbox notebooks and project notebooks, and that distinction should guide how you organize extracted code.

Sandbox notebooks Use sandbox notebooks for self-contained shareable notebooks and library examples. They are a good fit when the notebook should travel independently, declare its own dependencies, and remain easy to run outside a larger application context. If the notebook demonstrates a library, the library itself should still live in packaged code; the notebook becomes a consumer, not the container of all logic.

Project notebooks Use project notebooks when the notebook belongs to the same environment as the package or application. In that model, the notebook should import from the project normally and share the repository’s dependency management. This is often the right choice when notebooks are part of a maintained codebase rather than standalone artifacts.

That distinction helps avoid a common anti-pattern: using notebooks as semi-packaged islands with custom import tricks. If a notebook is part of the project, treat it like project code. If it is meant to be portable and self-contained, sandbox it deliberately.

A repository layout that scales

A simple layout works well in library-oriented repositories:

repo/
├── pyproject.toml
├── src/
│   └── my_library/
│       ├── __init__.py
│       └── features.py
└── examples/
    └── quickstart.py

In that layout, stable reusable logic belongs under src/my_library/. The notebook under examples/quickstart.py stays focused on orchestration, demonstration, and presentation. For editable local development against the package from the notebook script, marimo documents this command:

uv add --script examples/quickstart.py --editable

That pattern is especially useful for examples and library-adjacent notebooks. The notebook remains self-describing as a script, while the package code is developed in place. You avoid path manipulation, and you keep the import story honest.

Avoid sys.path hacks

If you find yourself inserting repository roots into sys.path, stop and treat that as an architecture smell. marimo recommends avoiding path manipulation when possible and instead packaging code properly. In multi-package setups, uv workspaces are a cleaner option than local import hacks.

Path manipulation creates three classes of failure:

editor and language-server confusion about import resolution;

environment-specific behavior that passes locally and fails in CI;

blurry ownership boundaries where notebooks appear reusable only because the current working directory happens to cooperate.

A packaged import is boring, and boring is what you want for maintainable notebook boundaries.

Testing pressure is a good extraction signal

When logic migrates from notebook cells into package code, standard Python testing advice becomes relevant immediately. Editable installs and a src layout are especially useful because they make import resolution predictable and keep tests honest about what is actually installed versus what merely happens to be on the path.

This does not mean every extracted helper needs a separate test file at once. It means that when code starts attracting tests, the package boundary usually becomes more valuable than the notebook-local one. Testability is not the only extraction signal, but it is a reliable one.

Preserving notebook readability after extraction

Extraction can improve maintainability while making the notebook harder to read if you apply it mechanically. A healthy notebook still shows the analytical composition clearly: where inputs come from, which transformations matter, what results are being rendered, and which steps are exploratory versus durable.

Keep notebook cells thin but meaningful. Prefer names that describe domain work rather than generic plumbing. If a cell is now just three imports and a call into an opaque helper stack, you may have moved the abstraction boundary too far. The notebook should remain inspectable by a reader who wants to understand the workflow without opening ten files.

A practical standard is that the notebook should answer “what is happening here?” while modules and reusable definitions answer “how is this implemented?” When both answers are only available by chasing imports, you have over-extracted.

Common boundary failures

Several anti-patterns appear repeatedly in notebook-heavy repositories.

Notebook files that secretly act as libraries If a notebook exports many unrelated helpers and classes, it is functioning as an unstructured module. Promote the stable code into package files or split the notebook’s reusable surface into focused top-level definitions.

Giant utility modules Overcorrecting by moving every helper into a shared utils.py just relocates the mess. Reuse needs domain boundaries and ownership, not one large dumping ground.

Reusables coupled to UI state A function that closes over widget values, local display assumptions, or notebook-only paths is not truly reusable. Keep UI and exploration inside ordinary @app.cell cells.

Premature abstraction If you extract before the shape of the logic stabilizes, you spread a weak abstraction across imports and tests. Duplication for a short time is often cheaper than locking in the wrong interface.

A durable rule of thumb

Keep reactive UI, one-off exploration, and notebook narrative in regular @app.cell cells. Move stable business logic into @app.function, @app.class_definition, or package modules when the code has a real audience beyond the local notebook. That boundary lets notebooks stay expressive and executable while the durable parts of

the system acquire names, imports, and tests that can survive repository-scale maintenance.

4.5 Testing Notebook Artifacts and Enforcing CI Quality Gates

Readable notebook files and clean module boundaries are only the start. A notebook becomes an engineering asset when you can prove, under automation, that it still imports, still executes, still respects its structural constraints, and still produces the behavior you expect as dependencies and surrounding code evolve. marimo’s most useful testing property is not that it invents a separate quality universe. It is that you can test notebooks like any other Python program while still preserving notebook-specific validation where ordinary Python tools are blind.

A fast local and CI sequence should look familiar:

marimo check 
pytest

That ordering is deliberate. marimo check fails fast on notebook-integrity problems before pytest spends time importing modules, collecting tests, or running expensive data setup. Then pytest exercises notebook logic and extracted code with the same mechanics you already use across the repository.

Why testing notebooks needs more than unit tests

A common failure pattern in notebook-heavy codebases is false confidence from good unit tests around extracted helpers while the notebook that composes them is never exercised. The reverse failure is just as common: teams run notebooks end to end but never isolate stable transformations into testable units. You need both.

The practical testing surface breaks into three layers.

Unit tests for extracted functions and classes

Stable logic that you moved into package modules or reusable notebook definitions deserves conventional unit tests. These tests should usually be small, deterministic, and independent of notebook runtime concerns. They are the cheapest way to verify business rules, parsers, transformations, and edge-case handling.

Notebook-level behavior tests

These tests verify the notebook as an assembled artifact. They answer questions such as: does the notebook still import; do key cells execute in the expected arrangement; does the notebook produce a known output contract; do notebook-local orchestration assumptions still hold after refactors?

Integration and environment-sensitive checks

These tests validate interactions with real or representative data, package constraints, file systems, secrets, and external services. They are usually slower, more selective, and more expensive, but they catch the failures that unit tests and notebook smoke tests cannot.

That layered design keeps the quality pipeline honest. If a notebook is only ever tested through deep integration runs, feedback becomes slow and brittle. If it is only tested through isolated unit tests, you never validate the artifact that collaborators actually edit and run.

Use notebook-aware validation before pytest

marimo’s official testing stance is to test notebooks like any other Python program, but it also provides notebook-aware integrity checks that generic tools do not express. Run marimo check before test execution and treat failures there as first-class CI failures.

marimo check notebooks/

The check command catches notebook-specific structural problems, including these breaking lint rules:

MB001: unparsable-cells

MB002: multiple-definitions

MB003: cycle-dependencies

MB004: setup-cell-dependencies

MB005: invalid-syntax

These rule codes are especially useful in CI logs because they point to notebook failure modes directly rather than forcing you to infer structure problems from downstream execution errors. For example, a cycle dependency may still leave the file looking like valid Python, but it is a broken notebook. Catch that before pytest ever starts.

Pytest works directly on notebooks

Because marimo notebooks are Python files, standard pytest usage remains valid. You can target a notebook file directly:

pytest test_pipeline.py

Normal test_*.py discovery also works, which means notebook tests can live naturally inside the repository’s existing test layout. This is a substantial simplification compared with notebook formats that require custom runners or export steps before test collection.

That said, marimo adds notebook-specific pytest behavior when pytest is installed. It discovers notebook test cells that are purely tests: test_ functions, Test classes, or @pytest.fixture definitions. The word purely matters. If a notebook cell mixes tests with helpers, imports, or constants, marimo skips that cell in notebook test execution.

That skip behavior is one of the most important structural rules to internalize because it surprises teams repeatedly.

Keep test cells pure

A healthy notebook test file separates test declarations from supporting code. Do not put a helper function, fixture constant, import side effect, and test_ function in the same cell and expect notebook test discovery to behave like a free-form script. If a helper is not itself the test, move it into another cell or, better, into a module.

A clean pattern looks like this:

import marimo 
import pytest 

app = marimo.App() 

@app.cell 
def _(): 
    from my_project.pipeline import run_pipeline 
    return (run_pipeline,) 

@app.cell 
def _(): 
    sample_rows = [ 
        {"user_id": 1, "revenue": 100.0}, 
        {"user_id": 2, "revenue": 50.0}, 
    ] 
    return (sample_rows,) 

@app.cell 
def _(run_pipeline, sample_rows): 
    def test_pipeline_row_count(): 
        result = run_pipeline(sample_rows) 
        assert len(result) == 2 
    return 

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

The structure keeps imports and data setup out of the test cell itself. That makes discovery more predictable and avoids skipped-cell confusion. If the setup grows larger than a few lines or becomes reusable, extract it into package code or a dedicated helper module.

When to test notebook files directly

Direct notebook tests are valuable when the notebook itself contains meaningful orchestration or assembly logic. Typical cases include:

data-loading and transformation sequencing;

report-generation notebooks with stable artifact contracts;

validation notebooks that compute and assert quality thresholds;

notebooks whose cell boundaries and reactive dependencies express important execution intent.

If the notebook is mostly a presentation layer over heavily extracted package code, direct notebook tests can be lighter-weight. A smoke test for importability or a check that expected outputs are produced may be enough. The deeper the notebook owns orchestration, the more notebook-level tests matter.

When extracted code should carry the testing load

As logic moves into modules or reusable top-level definitions, standard unit tests should become the dominant testing mechanism for that logic. This keeps feedback fast and failures precise. It also prevents notebook tests from turning into large end-to-end exercises for code that no longer belongs conceptually to the notebook.

The right split is usually this: modules own correctness at function and class boundaries, notebooks own composition correctness. If a failure can be localized to a pure transform, test it outside the notebook. If the failure concerns how notebook pieces are assembled or parameterized, keep the test close to the notebook artifact.

Doctest as a complement, not a substitute

doctest is useful for extracted reusable functions and classes that have stable docstrings and concise examples. It helps keep usage examples truthful and catches drift in small APIs. It is not a replacement for notebook-structure tests.

A minimal example in package code might look like this:

def normalize_region(name: str) -> str: 
    """ 
    Normalize a region label. 

    >>> normalize_region("North-America") 
    ’north america’ 
    """ 
    return name.strip().lower().replace("-", " ")

That kind of embedded example improves documentation quality and verifies tiny contracts. It does not tell you whether the notebook that calls normalize_region still composes its inputs correctly or whether the notebook’s dependency structure remains valid. Keep doctest focused on extracted reusable interfaces.

Designing a CI gate stack

A notebook-aware CI pipeline should be layered so that cheap structural failures stop the job early and expensive execution happens only after the basics pass. A practical default stack is:

set up Python consistently;

install dependencies;

run marimo check;

run pytest;

optionally publish structured test results such as JUnit XML.

In GitHub Actions, use actions/setup-python for consistent interpreter setup. A concise workflow looks like this:

name: ci 

on: 
  pull_request: 
  push: 
    branches: [main] 

jobs: 
  test: 
    runs-on: ubuntu-latest 
    steps: 
      - uses: actions/checkout@v4 

      - uses: actions/setup-python@v5 
        with: 
          python-version: "3.11" 

      - name: Install dependencies 
        run: > 
          pip install marimo pytest 

      - name: Notebook integrity 
        run: marimo check . 

      - name: Test suite 
        run: pytest -q --junitxml=build/junit.xml

This workflow is intentionally ordinary. The notebook-specific logic is concentrated in the integrity gate, not in a separate CI universe. That makes maintenance easier and keeps notebooks inside the same policy envelope as the rest of the codebase.

Staging tests by cost and confidence

Not every notebook should execute fully on every pull request. The right policy depends on runtime cost, data access, and failure criticality.

Run on every pull request

Choose this for small but important notebooks: data validation artifacts, contract checks, lightweight reports, and notebooks whose failure would block common development paths.

Run nightly or on protected branches

Choose this for expensive notebooks, notebooks that need substantial datasets, or workflows that touch external systems where PR-time feedback would be too slow or noisy.

Split the notebook if CI cost is persistently high

If a notebook is too slow or environment-heavy to test at the right cadence, that often signals a structural problem rather than a CI tuning problem. Extract stable logic, reduce expensive external dependencies, or separate smoke-testable paths from heavy production runs.

This staging approach keeps CI credible. If tests are too slow, developers stop trusting them. If tests are too shallow, they stop meaning anything.

Environment drift and hidden dependencies

Ephemeral CI runners are brutally effective at surfacing assumptions that local machines hide. A notebook may pass locally because the author has credentials exported, a data file cached, or a package installed outside the declared environment. In CI, those assumptions disappear.

Defend against that by making inputs explicit, keeping environment setup reproducible, and failing with legible messages when prerequisites are absent. If a notebook depends on external state, say so in configuration and test markers rather than letting the failure emerge as a mysterious import error or missing file halfway through execution.

This is also where repository structure matters. Notebooks that rely on package code should import it through the installed environment or a clean editable-install workflow, not through ad hoc path mutations. CI is where those shortcuts finally stop being invisible.

Common notebook-specific failure modes

Even with standard tooling, notebooks retain some distinctive quality hazards.

Skipped test cells

The most common one is mixed-content cells that marimo skips during notebook test discovery. Keep tests isolated from helpers and imports.

Presentation-noise assertions

Tests that compare rendered formatting or brittle full-output snapshots tend to fail for cosmetic reasons. Prefer assertions on semantic content: row counts, schema, key values, presence of expected artifacts, or invariant properties.

Over-testing exploratory notebooks

Do not indiscriminately execute every exploratory notebook in CI. That burns time and creates fragile gates around artifacts that were never meant to be durable. Apply path-based or ownership-based policies so durable notebooks receive durable enforcement.

Module-only confidence

A repository with excellent module tests can still ship broken notebook assembly. Notebook smoke tests remain necessary even when most logic is well extracted.

Flaky external integrations

If a notebook depends on unstable network services or mutable remote data, isolate those tests, mark them appropriately in your pytest policy, and avoid making them the default gate for all pull requests.

A practical quality contract for notebook repositories

A maintainable notebook repository usually converges on a compact contract:

Every durable notebook passes marimo check.

Reusable logic has ordinary unit tests.

Notebook files with meaningful orchestration have direct pytest coverage or smoke tests.

Expensive or environment-sensitive checks are staged separately.

CI uses the same Python setup and package-resolution discipline as the rest of the repository.

That contract is what turns pure-Python notebooks into enforceable software artifacts instead of tolerated exceptions. Once notebooks can be reviewed, edited, run, and modularized with ordinary tools, the final step is to hold them to ordinary evidence: structural validity, automated tests, and repeatable CI outcomes.

Chapter 5
Interactive Data and AI Workflows

Interactive notebooks often fail at the exact moment they become useful: controls drift away from computation, exploratory filters become hidden state, and AI demos collapse into callback-heavy glue code. This chapter explains how marimo avoids that failure mode by making interface elements first-class reactive inputs, then builds from that foundation toward table-driven analysis and disciplined experiment consoles for data and AI work.

5.1 Treating UI Controls as First-Class Reactive Inputs

Notebook interactivity often fails at the exact moment it becomes analytically useful. You add a slider for a threshold, a dropdown for a cohort, or a text field for a query. The notebook still runs, but the execution model becomes harder to trust. One visible output reflects the new control value, another was computed before the last change, and a third depends on hidden state that no longer matches the interface. The problem is not that the controls are interactive; the problem is that many notebook systems treat interaction as a side channel layered on top of code execution.

marimo takes a different position. A control is not merely a widget that emits events. It is a value-producing object that participates in the same dependency graph as ordinary Python definitions. That single design choice changes how you reason about correctness, recomputation, and notebook structure. If a control feeds a downstream calculation, that dependency is part of the notebook’s execution semantics rather than an informal convention in your head.

A minimal example makes the contract concrete. You define a control, bind it to a global variable, consume its .value in downstream code, and let the runtime propagate changes through the graph.

import marimo as mo

threshold = mo.ui.slider(0, 100, value=25) 
threshold

selected_label = f"Current threshold: {threshold.value}" 
selected_label

scores = [12, 24, 37, 51, 88] 
filtered_scores = [ 
    score for score in scores 
    if score >= threshold.value 
] 
filtered_scores

When you move the slider, marimo can react because threshold is a globally bound UI element and downstream cells read threshold.value. The runtime does not need an explicit callback registration to discover what depends on the

slider. It uses the same dependency logic it uses for ordinary Python code: cells define globals, read globals, and form a directed acyclic graph from those relationships. A user interaction changes a value node in that graph, and dependent cells re-execute or become stale according to the runtime mode.

Why value semantics matter

That behavior looks simple, but it solves a deep notebook problem. In a callback-oriented design, the notebook has two models of computation: the visible code you execute manually and the event wiring that updates outputs behind the scenes. Those two models drift apart quickly. A callback might mutate state that no cell explicitly declares. A control might update a rendered artifact without updating the data used by a later cell. Once that happens, you can no longer inspect the notebook and infer its current state from code and outputs alone.

Value semantics keep the model single. A UI control contributes a value. Downstream cells consume that value as they would consume any other variable. If you can trace a Python reference, you can trace the interactive dependency. That is the mental model to hold throughout analytical work in marimo: controls are part of the declarative surface of the notebook.

This vocabulary is precise enough to reason about nontrivial behavior:

Control A UI object such as a slider, dropdown, text input, table, or chart-backed selector.

Binding The act of assigning that UI object to a global variable. In marimo, this is operationally significant. Interacting with a displayed UI element only triggers reactive execution when the element is assigned to a global variable.

Value The Python-facing state exposed by the control through .value. Downstream computation should depend on the value, not on ad hoc display side effects.

Dependency A read of a globally defined name from another cell. marimo uses static analysis of global definitions and references to determine these dependencies.

Invalidation The process by which a changed upstream value causes downstream cells to be re-run or marked stale.

Reactive execution Execution driven by dependencies rather than by cell position on the page. This gives deterministic order based on the graph rather than on notebook layout.

If you already understand stale cells and deterministic execution from the general reactive model, the extension here is narrow but important: UI values are first-class upstream inputs. A slider change is not conceptually different from changing the value of a Python variable in an earlier cell, except that the source of the new value is a user interaction rather than an edit to code.

Binding controls correctly

The most common failure mode is also the simplest: displaying a control without binding it to a global name. If you want a control to participate in reactive execution,

assign it to a variable that downstream cells can reference.

limit = mo.ui.slider(1, 10, value=3) 
limit

top_items = list(range(10))[:limit.value] 
top_items

This pattern is intentionally plain. The control exists in one cell, the domain variable appears through limit.value, and the dependent computation uses that value without any event-handler glue.

You can also place controls close to explanations and outputs by embedding them in markdown with f-strings. That helps when the meaning of a parameter is easier to understand in context than in a separate controls panel.

rate = mo.ui.slider(0, 20, value=5)

mo.md(f""" 
Choose an error rate target: {rate} 

The current target is {rate.value}\%. 
""")

The key semantic point remains the same. The markdown cell renders the control, but the reactive value still lives in rate.value, and downstream cells should depend on that value explicitly.

From widget intuition to graph intuition

Once you treat controls as graph nodes, several notebook design choices become clearer. You stop asking, “What callback should fire when this slider changes?” and start asking, “What computations should depend on this value, and how broad is that dependency fan-out?” That shift is subtle but operationally significant.

A useful way to picture the model is as a small dependency graph in which controls, intermediate transformations, and rendered outputs are peers in the same execution structure.

This graph-oriented view explains why marimo can update outputs without callback plumbing. The runtime does not need a hand-written mapping from widget events to handlers. It already knows that a downstream cell reads a global name whose value has changed.

Designing around domain parameters

A control should rarely be the real abstraction boundary in analytical work. The real boundary is usually a domain parameter: confidence threshold, date window, feature family, sample size, query string, selected cohort. If you expose those concepts cleanly, the notebook remains intelligible as interactivity grows.

That often means normalizing raw UI values before they touch expensive logic. For example, a slider may emit an integer percentage, but your analytical code may want a probability in the interval .

threshold_pct = mo.ui.slider(0, 100, value=80) 
threshold_pct

threshold = threshold_pct.value / 100.0 
threshold

scores = [0.11, 0.42, 0.79, 0.83, 0.97] 
accepted = [ 
    score for score in scores 
    if score >= threshold 
] 
accepted

That intermediate cell may look trivial, but it creates a clean dependency boundary. The control is responsible for gathering a user-facing value. The normalization cell is responsible for translating that value into a stable analytical parameter. Downstream logic becomes easier to test, reuse, and inspect because it no longer knows anything about slider ranges or display units.

Execution stakes: correctness first

The first stake is correctness, not convenience. Interactive notebooks fail when the visible interface no longer guarantees the visible outputs. marimo’s graph model reduces that risk, but it does not remove the need for discipline.

A few rules help preserve correctness:

Bind each meaningful control to a global variable.

Read .value in downstream cells rather than manually caching copies in unrelated places.

Keep transformations pure with respect to UI state whenever possible: derive results from current values instead of mutating shared state.

Separate control definition, parameter normalization, and expensive computation into distinct cells when they represent distinct dependency boundaries.

These rules matter because static dependency analysis is strongest when your cells expose clear global definitions and clear reads. If one large cell creates controls, performs file I/O, transforms data, and renders outputs, the graph still exists, but it becomes too coarse to reason about efficiently. A small control change may then trigger far more work than necessary, and you lose the ability to inspect where time and state are actually flowing.

Execution stakes: performance and recomputation

The second stake is recomputation cost. Eager reactivity feels natural for lightweight analytical steps: move a slider, get an updated summary, compare alternatives quickly. But the same behavior can become expensive if a control fans out into database access, model inference, large joins, or high-resolution plotting.

marimo supports a lazy runtime mode, available through the runtime settings, for notebooks where immediate recomputation would be wasteful. In eager mode, changing a control can automatically update dependent cells. In lazy mode, dependent cells can remain stale until you choose to recompute. The trade-off is straightforward:

Eager mode Best when downstream work is cheap enough that immediate feedback improves analysis.

Lazy mode Best when downstream work is expensive enough that accidental recomputation would interrupt thought or waste resources.

Use lazy mode when a notebook has one or more of these signals:

a control feeds network-bound or database-bound operations;

an interaction triggers model scoring or inference over many records;

a small parameter adjustment causes heavy rendering work;

you need to inspect stale downstream state before deciding whether to refresh.

Use eager mode when the notebook is acting more like an exploratory instrument panel, where fast feedback is part of the analysis itself.

Anti-patterns that break the model

Several habits from traditional notebooks work against reactive clarity.

Over-granular controls. If you expose every low-level parameter directly, you widen the dependency surface and increase recomputation frequency. A better pattern is to expose a smaller number of domain-level controls and derive secondary settings downstream.

Controls attached directly to heavyweight paths. If a slider value goes straight into expensive I/O or inference, every small adjustment becomes costly. Insert a stable boundary: normalize parameters, cache reusable data if appropriate outside the notebook’s reactive semantics, and isolate truly expensive cells.

Mixed-purpose cells. A cell that defines a control, loads data, computes aggregates, and renders a chart obscures both semantics and cost. When that cell reruns, you cannot tell whether the expense came from control handling, transformation, or rendering.

Treating UI as ad hoc global state. A control value should be read as an explicit dependency. Avoid copying its value into unrelated mutable state or building side-channel logic around it. That recreates the very ambiguity the reactive model is meant to eliminate.

User-driven changes versus code-driven changes

A useful distinction in marimo is the difference between changing code and changing a control value. Both can affect downstream cells, but they do so at different layers of the notebook state.

When you edit code, you may alter definitions, dependency relationships, or both. The graph itself can change because the set of globals read or defined by a cell can change. When you interact with a control, the graph usually stays the same; what changes is the current value at one node in that graph. That distinction matters when debugging.

If outputs update unexpectedly after a code edit, inspect dependency structure: did a cell start reading a new global, stop defining one, or collapse multiple responsibilities into one? If outputs update too broadly after a control interaction, inspect dependency fan-out: is the UI value referenced by too many expensive cells, or did you bind it too close to heavyweight work?

This is also why deterministic execution order in marimo is easier to trust than page-order execution. The order derives from dependencies, not from where the cell appears visually. A UI interaction therefore follows the same execution principles as any other upstream change.

A disciplined operational flow

For most analytical notebooks, the durable pattern is simple:

Define a control and bind it to a global variable.

Translate its .value into a domain parameter.

Derive filtered, transformed, or scored data from that parameter.

Render outputs in downstream cells that depend only on the derived artifacts they need.

That flow may look almost too modest to mention, but it is exactly what keeps interactivity legible. Once you internalize that a user input is just another dependency source, you can scale from a single slider to richer interfaces without changing your reasoning model. Controls stop being magical UI objects and become ordinary participants in the notebook’s dataflow. That is what makes interactive analytical work in marimo tractable: the interface, the code, and the execution model all describe the same state.

5.2 Composing Analytical Interfaces Without Callback Plumbing

The first interactive control in a notebook usually feels harmless. You add a slider for a threshold, route it into one plot, and the result looks cleaner than a hard-coded constant. The trouble starts when a second or third control arrives. A date selector changes one table but not another. A category filter must update both a chart and a summary metric. A text input alters a query, which should also invalidate a cached transformation. In callback-oriented systems, that complexity tends to surface as branching event logic, duplicated transformations, and invisible state transitions. The interface becomes harder to inspect than the analysis itself.

marimo avoids that trap by letting controls bind directly to Python values and by driving recomputation from code references rather than from hand-written event wiring. You do not compose an analytical interface by attaching handlers to widgets. You compose it by arranging a dependency graph whose inputs happen to include UI values. That keeps interaction and computation in the same semantic model, which is the only way a notebook with several controls remains readable after the prototype stage.

A small threshold explorer shows the shape of a maintainable design. The code uses only bound controls, ordinary Python transformations, and downstream cells that consume explicit values.

import marimo as mo

scores = [0.12, 0.18, 0.29, 0.44, 0.57, 0.63, 0.74, 0.91] 
labels = ["A", "A", "B", "B", "B", "C", "C", "C"]

threshold_pct = mo.ui.slider(0, 100, value=50) 
threshold_pct

selected_group = mo.ui.dropdown( 
 options=["all", "A", "B", "C"], 
 value="all" 
) 
selected_group

threshold = threshold_pct.value / 100.0 
group = selected_group.value

rows = list(zip(scores, labels)) 

if group == "all": 
 filtered_rows = rows 
else: 
 filtered_rows = [ 
 row for row in filtered_rows 
 if row[1] == group 
 ] 

accepted_rows = [ 
 row for row in filtered_rows 
 if row[0] >= threshold 
] 
accepted_rows

accept_rate = ( 
 len(accepted_rows) / len(filtered_rows) 
 if filtered_rows else 0.0 
) 
accept_rate

Nothing here behaves like a callback tree. The controls define values. A normalization cell converts raw UI outputs into domain parameters. A filtering cell derives the working dataset. A metric cell computes a downstream artifact from that dataset. If you change the selected group, the metric updates because it depends on filtered_rows. If you move the threshold, only cells downstream of threshold need attention. This is the core authoring discipline: arrange the notebook so every interaction flows through explicit data dependencies.

Why callback thinking scales poorly

Callback-heavy interfaces fail for reasons that matter directly to analytical work. The first reason is duplication. If two outputs depend on the same filtered dataset, callback code often repeats filtering logic in multiple handlers. The second reason is state drift. One handler updates a plot, another updates a table, and a third forgets to recompute a summary statistic. The third reason is inspectability. Once state changes are distributed across event handlers, you can no longer infer notebook behavior just by reading cell definitions and variable references.

Even when a callback-based notebook appears to work, the operational burden grows with every new control. A single threshold slider might need one handler. A threshold slider plus a cohort selector plus a display mode toggle can easily imply several pairwise interactions and recomputation branches. In marimo, those combinations do not require explicit coordination code as long as you express outputs as functions of values. The runtime already knows which cells read which globals. When a control value changes, dependency propagation follows those

reads.

That difference is not cosmetic. It means your design work shifts from event orchestration to dependency management. You stop asking how to notify several outputs that something changed. You ask which outputs should depend on which normalized parameters, and how coarse or fine each dependency boundary should be.

Define controls around domain concepts

Analytical interfaces become maintainable when controls represent concepts from the problem domain rather than raw UI mechanics. A slider should represent a confidence threshold, not merely an integer. A dropdown should represent a segment or feature family, not merely a menu choice. This keeps the interface semantically aligned with the transformations that follow.

The practical pattern is to separate raw controls from normalized parameters. The previous example already did this by converting a percentage slider into a floating-point threshold. That separation becomes more valuable as the notebook grows. If you later replace the slider with a different control, you only change the cell that produces the normalized parameter. Downstream logic remains stable.

A good rule is to ensure that most analytical cells refer to domain variables such as threshold, group, or date_window, not directly to the UI object itself. Leave the UI-specific detail at the notebook edge. This mirrors good module design in ordinary Python: isolate representation choices from computation.

Use cell boundaries to shape the dependency surface

marimo’s reactivity is declarative, but the precision of recomputation still depends on how you factor cells. If you collapse too much work into one cell, the runtime sees a larger unit of dependency than necessary. If you split every line into a separate cell, the notebook becomes noisy and harder to read. The right cell granularity makes dependencies legible without fragmenting the analytical story.

A useful decomposition for multi-control interfaces has four layers.

Control cells: define and display UI elements.

Parameter cells: convert control values into domain-level variables.

Transformation cells: derive filtered or enriched data from those parameters.

Render cells: present plots, tables, or summary metrics from derived data.

That layout gives you two operational benefits. First, it narrows recomputation. If a display formatting option changes, you can often avoid rerunning upstream data preparation. Second, it keeps the graph readable. You can inspect the notebook and see where user intent becomes analytical state, where analytical state becomes data, and where data becomes presentation.

Consider a more structured flow for comparative analysis:

Define controls for threshold, cohort, and comparison mode.

Normalize those values into a small parameter set.

Build one canonical filtered dataset from that parameter set.

Compute all statistics from the canonical filtered dataset.

Branch into alternative views only at the render layer.

That last step is easy to neglect. Many notebooks fork too early, producing several similar transformations for different displays. Instead, keep one authoritative analytical representation for as long as possible. Create separate views only when the outputs truly require different presentation logic. This keeps business logic out of rendering cells and prevents subtle divergence between views.

Prefer pure transformations over incidental state

A reactive notebook stays predictable when downstream artifacts are pure functions of upstream values. In practice, that means you should derive data and metrics from current control values rather than mutating shared state across cells. The less your notebook relies on hidden mutation, the more reliable reactivity becomes.

This matters for another reason documented by marimo’s product behavior: deleting a cell scrubs its variables from program memory. That design helps avoid the hidden-state problems common in traditional notebooks, where variables can outlive the code that created them. To benefit from that behavior, write cells so their outputs are explicit and reproducible from upstream dependencies. Do not assume

that a mutable object stored somewhere off to the side will remain a trustworthy source of truth.

When you need reusable computational kernels, put them in plain Python functions and call them from reactive cells. That preserves a clean boundary between reusable logic and notebook orchestration. The notebook then becomes a graph of parameter choices and function applications rather than a tangle of mutable intermediate state.

A before-and-after mental model

The difference between callback-shaped design and reactive composition is easiest to see in how you think about updates.

Callback-style mental model: “When the threshold changes, run the filter update. When the filter update completes, refresh the plot. If the category also changed, run the metric refresh.”

Reactive mental model: “The filtered dataset depends on threshold and category. The plot and metric depend on the filtered dataset.”

The second description is shorter because it captures structure rather than events. That structural description is also easier to modify. If you add a new output, you connect it to the filtered dataset. If you add a new parameter, you update the transformation cell that consumes it. You do not edit a distributed collection of handlers.

Page position is not execution order

One practical advantage of dependency-driven composition is that notebook layout becomes less fragile. marimo determines execution order from variable references rather than from cell position on the page. You can therefore organize an analytical interface for readability without relying on top-to-bottom execution habits to keep it correct.

This matters when you are designing a notebook intended for repeated use by yourself or by colleagues. You may want a concise dashboard-like layout near the top, detailed derivations nearby, and lower-level helper logic elsewhere. In a traditional notebook, that kind of arrangement can invite execution-order confusion. In marimo, correctness comes from the graph. If a plot cell reads a derived dataset, and that dataset reads normalized parameters, the runtime respects those dependencies regardless of visual ordering.

The practical implication is not that layout no longer matters. It matters a great deal for readability. But you can optimize layout for comprehension instead of for execution safety. That is a substantial improvement for complex interfaces.

Responsiveness versus recomputation cost

Declarative composition is not free. Every new dependency edge is a potential recomputation path. If a control influences a heavy transformation, then a small interaction can trigger expensive work. The answer is not to abandon reactivity; it is to design the graph so expensive work sits behind stable boundaries.

Several signals tell you that a dependency surface is too broad:

A cosmetic control forces upstream data loading to rerun.

Several outputs rederive the same filtered dataset independently.

A single convenience variable is read by many heavy cells.

Small UI adjustments trigger network-bound or model-bound work.

When you see these signals, narrow the graph. Move formatting choices into render cells. Consolidate repeated transformation logic into one authoritative cell. Separate cheap parameter normalization from expensive data preparation. If a notebook remains expensive even after refactoring, use lazy runtime mode so interactions do not automatically trigger every downstream computation.

A clean interface often benefits from staging. Keep source data loading in a stable cell. Keep lightweight filtering in a reactive layer driven by controls. Keep heavyweight scoring or model inference in a downstream cell that you recompute deliberately when needed. That arrangement preserves the declarative model while respecting cost.

How many controls are too many

Interface granularity is a modeling decision, not a cosmetic one. Too many controls create a wide dependency surface and force users to reason about interactions they may not care about. Too few controls collapse distinct analytical choices into one overloaded parameter and make the notebook less expressive.

Good controls meet three tests:

They represent decisions users actually need to vary.

They affect a coherent slice of downstream computation.

Their interactions with other controls are conceptually explainable.

If a control fails those tests, remove it or derive its effect from another variable. For example, do not expose both a raw threshold integer and a separately scaled probability if one is merely a reformulation of the other. Do not expose visualization-specific toggles as upstream analytical inputs if they belong only in the final render stage. Each control should justify the dependency edges it creates.

Common anti-patterns

Several mistakes recur when analytical interfaces evolve from quick prototypes.

Burying logic inside render cells. If a chart cell also performs filtering, aggregation, and label normalization, you cannot reuse those computations cleanly in a table or metric view. Move analytical logic upstream and keep render cells thin.

Using controls as ad hoc globals. Controls should produce explicit parameter values, not serve as a miscellaneous state bucket consulted from arbitrary cells. Normalize control output early and propagate well-named variables.

Creating broad convenience dependencies. A single dictionary or tuple that bundles many unrelated choices may look tidy, but if many downstream cells read it, every tiny change can invalidate too much work. Group values only when they genuinely change together.

Recomputing the same derived artifact repeatedly. If both a plot and a summary card need the same filtered dataset, compute it once and share it downstream. The notebook graph should expose common structure rather than duplicating it.

A durable authoring discipline

When you build analytical interfaces this way, the notebook remains inspectable even as the surface grows. Controls provide values. Parameter cells define domain concepts. Transformation cells produce authoritative derived artifacts. Render cells present those artifacts in one or more views. The absence of callback plumbing is not merely a convenience feature; it is what keeps interaction aligned with reasoning. As the notebook accumulates threshold sweeps, cohort filters, comparative views, and richer outputs, you can still explain every update by following ordinary Python references through the graph.

5.3 Driving Data Exploration with Reactive Tables and Derived Views

Table exploration is where notebooks most often slip into irreproducibility. You sort a few columns, filter to suspicious rows, inspect three records that look wrong, and mentally carry that subset into the next calculation. The visible table helped you discover something real, but the notebook often has no durable representation of what you actually selected. Hours later, the outputs still render, yet it is no longer clear which records they reflect. That gap between exploratory interaction

and computational state is small at first and damaging by the end of an analysis.

marimo makes tables useful in a stricter way. An interactive table is not only a display surface for browsing data. It can also participate in the notebook’s reactive model, so the current exploratory state can become an explicit input to downstream computation. The same principle applies to chart-based interaction. Documentation identifies mo.ui.table, mo.ui.altair_chart, and mo.ui.plotly as core data exploration primitives, and the data exploration guidance states that table rows or chart points can be selected and returned to Python as subsets of the original dataframe. That capability turns exploration into a derived-view workflow: the user’s current selection becomes a new dataframe input for summaries, visualizations, or model probes.

A concrete pattern makes the distinction clear. Start with a source dataframe, expose it through an interactive table, and then treat the current table-driven subset as a downstream analytical input. The exact selection accessor names were not confirmed from canonical API reference pages in the retrieved material, so the code below intentionally stops at the verified constructor and then describes the selection flow in prose rather than inventing an undocumented method.

import marimo as mo 
import pandas as pd

df = pd.DataFrame( 
 { 
 "ticket_id": [101, 102, 103, 104, 105, 106], 
 "priority": ["high", "low", "high", "medium", 
 "low", "high"], 
 "team": ["api", "ui", "api", "ops", "ui", "ops"], 
 "latency_ms": [240, 90, 310, 180, 110, 420], 
 "error_rate": [0.08, 0.01, 0.12, 0.03, 0.02, 0.18], 
 } 
)

tickets = mo.ui.table(df) 
tickets

From here, you use the table’s interactive capabilities—paging, search, filter, sort, and selection—to narrow attention. When the table returns a selected or filtered subset to Python, that subset becomes the authoritative input for downstream cells. The key idea is not the accessor name; it is the state transition. The current exploratory subset is no longer trapped in front-end state. It becomes notebook state.

Display tables versus data-bearing views

A useful distinction is between a table that helps you look at data and a table that produces data. Both are interactive at the user interface level, but only the second participates directly in the analytical graph.

A display table improves inspection. You may page through records, sort by a column, or search text to understand the dataset. Those actions are valuable, but they do not necessarily define an explicit input used by later cells. If you stop there, the table remains a rich view rather than a computational object.

A data-bearing view goes further. Filtering, searching, or selecting rows produces a subset that downstream cells consume as a dataframe-like input. That input can feed summary statistics, plots, diagnostics, or even expensive model-side analysis. At

that point, exploration is no longer merely visual. It becomes part of the notebook’s declared state.

That semantic boundary matters because many analytical mistakes come from treating a visible table as if it were already the authoritative filtered dataset. A table sorted by highest error rate may make certain rows prominent, but unless that state is explicitly bound into downstream computation, later metrics still refer to the original source data. marimo’s reactive model lets you avoid that mismatch by treating the interactive subset as an input like any other control-derived value.

What a derived view actually is

A derived view is not a copy-and-paste subset and not an informal mental bookmark. It is a computation rooted in upstream data plus interactive state. The source dataframe remains the canonical input. The table or chart contributes a current selection, filter, or narrowed view. The notebook then derives a new object from that interaction and routes it forward.

That structure gives you a disciplined exploration pipeline:

Load or compute a source dataframe.

Expose it through mo.ui.table or an interactive chart.

Let search, filter, sort, or selection produce a current view.

Bind that view into Python as a subset of the original data.

Compute summaries, plots, or diagnostics from the subset.

The pipeline matters because it preserves provenance. You can still trace every downstream result back to the original dataframe and the current interactive narrowing step. You are not leaving analytical intent behind in the browser.

Search, filter, sort, and selection do different jobs

These interactions are often lumped together as “table state,” but they carry different analytical meanings. You should keep them conceptually separate.

Search is a convenience for narrowing by textual or lightweight criteria. It is useful for quickly isolating records that match a name, code, or fragment. Search helps discovery, but unless you bind the resulting subset into computation, it remains an inspection aid.

Filter applies a predicate-like narrowing. This is usually the cleanest transition from exploration to analysis because it defines a subset with a stable semantic meaning: all records matching a condition or current interactive filter state.

Sort changes presentation order. Sorting helps prioritize attention, but it does not necessarily define a meaningful analytical subset. A common error is to sort a table by error rate, visually inspect the top rows, and then behave as if “top rows” were a durable analytical category. Unless you explicitly derive a subset from that ordering, the sort is just a viewing aid.

Selection identifies records of direct interest. This is often the most natural bridge into downstream computation because the selected rows can be treated as a dataframe subset and used immediately in later cells.

These distinctions shape how you design a notebook. Use filters and selections when you want table interaction to drive computation. Use sorting and paging primarily to improve inspection unless you have a deliberate rule for converting them into an explicit subset.

Stable identifiers matter more than row positions

Once an exploratory subset feeds later analysis, record identity becomes critical. Never rely on displayed row position as the meaning of a selection. Sorting, filtering, and pagination can all change which record appears in row three or row ten. If downstream work depends on row positions rather than durable identifiers, you risk misaligning summaries, diagnostics, or follow-up joins.

The source dataframe should therefore carry stable keys such as ticket_id, customer identifiers, event IDs, or timestamps with sufficient uniqueness. When the table returns a subset, preserve those identifiers and use them as the basis for later operations. If you need to fetch additional context, join to another dataframe, or run a model probe on selected examples, stable identifiers keep the relationship sound.

This is especially important when table-driven exploration becomes part of quality review or model evaluation. A selected row should refer to the same entity regardless of sort order, pagination state, or whether you later narrow the table by additional filters.

A practical pattern for downstream summaries

Suppose the current table subset contains suspicious high-latency API tickets. Once that subset is available in Python, you can derive downstream summaries in ordinary cells. The code for accessing the selected subset depends on version-specific accessors that were not confirmed in the retrieved material, so the pattern is best expressed in prose: take the dataframe subset produced by the interactive table and compute metrics from it exactly as you would from any pandas dataframe.

The structure of those downstream cells is straightforward:

Compute count, mean latency, or error-rate summaries from the subset.

Render a focused comparison view for just the selected records.

Join the selected keys to external metadata if you need richer details.

Route only the reduced subset into expensive analyses.

That last step is often where reactive tables pay for themselves. Exploratory narrowing lets you reduce a large dataset to a small, analytically meaningful slice before running heavy diagnostics or model-side checks. Instead of shipping a full dataframe through every expensive step, you use the current view to define the working set.

Chart-driven derived views

The same model extends naturally to charts. Documentation material identifies mo.ui.altair_chart and mo.ui.plotly as interactive primitives, and the exploration guidance states that chart point selections can be returned to Python as subsets of the original dataframe. That gives you a powerful alternative when the analytical question is geometric rather than tabular.

A chart is often the better exploratory surface when you need to see clusters, outliers, or threshold effects. A scatter plot can let you select anomalous points, and that selected subset can flow into a details table, a metrics cell, or a follow-up inspection of source records. The value proposition is the same as with tables: the selection is not hidden front-end state. It becomes a Python-side subset with explicit downstream dependencies.

A compact example of the chart constructor stays within verified API names:

import marimo as mo

chart = mo.ui.altair_chart(...) 
chart

The exact chart specification and selection accessors depend on APIs not confirmed in the retrieved material, so it is safer to state the pattern directly: when a chart selection yields a subset, treat that subset as the canonical input for later cells rather than recomputing an approximate equivalent elsewhere.

Large datasets and responsiveness

The documentation describes interactive dataframes with paging, search, filter, and sort aimed at large datasets. That is operationally important. Table-centric exploration is most valuable when the source data is too large for naïve browsing but still small enough, or sufficiently summarized, for interactive narrowing to remain useful.

Even then, you need to design for responsiveness. A few signals tell you that the current notebook structure is too heavy for table-driven interaction:

every filter change triggers expensive downstream work;

one table selection fans out into many heavyweight cells;

the notebook recomputes transformations that could be shared;

users are forced to wait for analyses before they can continue exploring.

When that happens, reduce the dependency surface. Keep source loading in a stable upstream cell. Use the table to produce a narrower working subset. Compute lightweight summaries immediately, and defer expensive probes to separate downstream cells or to lazy runtime mode. Reactive exploration should tighten focus, not amplify computational waste.

When to use tables instead of dedicated controls

A table is the right input surface when the analytical intent is still partly open-ended. You are browsing real records, discovering anomalies, and deciding what deserves scrutiny. Dedicated controls such as sliders and dropdowns are better when the parameter space is already known: date ranges, thresholds, segments, feature families, and similar domain variables.

This decision rule keeps the interface honest. Use tables for record-level inspection and opportunistic discovery. Use explicit controls when you already know the dimensions along which analysis should vary. If you try to force a table to act as a general-purpose parameter panel, you blur the line between browsing and decision capture. If you force all exploration through dedicated controls, you lose the ability to inspect the concrete records that often reveal what the parameters should be.

The strongest notebooks combine both. A control might define a broad cohort, and a table might then let you inspect and select the interesting members of that

cohort. The table subset becomes a second-stage reactive input rather than a disconnected visual aid.

Common failure modes

Several mistakes undermine table-centric workflows.

Treating the visible table as the filtered dataset. A display can look filtered without any downstream cell consuming that view. Bind the resulting subset explicitly before using it analytically.

Letting sort order leak into meaning. Sorted rows are not a category. If you need the top-k items, compute a top-k subset explicitly from stable identifiers and values.

Overloading one table with too many roles. A single table should not simultaneously serve as a browser, an editor, a parameter panel, and a final decision log. Split those responsibilities so the dependency graph remains readable.

Feeding ephemeral row positions downstream. Always carry durable keys, especially if later cells join data, fetch details, or trigger external analysis.

Using reactive tables as a substitute for a real transformation pipeline. Exploration is not a replacement for reproducible preprocessing. If a subset definition belongs in data preparation, express it directly in code or SQL and use the table for inspection and refinement, not as the sole source of analytical truth.

Operational shape of a trustworthy workflow

A reliable table-centric notebook has a clear center of gravity. The source dataframe remains canonical. The interactive table or chart narrows that dataframe through explicit exploratory actions. The resulting subset becomes the input to downstream computations, not just a temporary visual convenience. Once you adopt that pattern, filtering, searching, and selection stop being hidden front-end gestures and start acting like any other reactive inputs. The notebook can then preserve both the speed of exploration and the discipline of explicit state.

5.4 Designing AI Experiment Consoles for Parameter Tuning and Evaluation

AI notebooks become unreliable long before they become sophisticated. The first few controls feel productive: a dropdown for model choice, a slider for temperature, another for chunk size or retrieval depth, and perhaps a table for inspecting outputs. Soon the notebook behaves like an experiment console, but not a trustworthy one. It is no longer obvious which parameter settings produced the visible metrics, whether a table reflects the current run or a stale one, or whether an interesting example was cherry-picked from an earlier interaction. The problem is not interactivity itself. The problem is undisciplined interactivity that blurs configuration, execution, and evaluation into one surface.

marimo is well suited to AI experiment consoles because its interaction model keeps UI values, code, and outputs in the same reactive graph. Official documentation verifies the broader ingredients you can rely on: AI-native positioning, built-in AI assistants, optional extras via marimo[recommended], collaboration with agent CLIs using marimo pair, and synchronized chat interfaces as a class of UI element. The retrieved material does not confirm stable, notebook-side APIs for dedicated LLM experiment widgets or evaluation helpers, so the safest pattern is to build consoles from generic primitives: sliders, dropdowns, tables, markdown, and runtime controls such as eager or lazy execution. That constraint is useful. It forces you to design the experiment surface around explicit parameters and derived artifacts rather than around opaque helper abstractions.

A minimal console starts with parameters and keeps them separate from evaluation logic. The example below stays within verified API names and uses ordinary Python data structures for the evaluation layer.

import marimo as mo

model_choice = mo.ui.dropdown( 
    options=["baseline", "reranker", "hybrid"], 
    value="baseline" 
) 
model_choice

temperature = mo.ui.slider(0, 100, value=20) 
temperature

retrieval_depth = mo.ui.slider(1, 20, value=5) 
retrieval_depth

config = { 
    "model": model_choice.value, 
    "temperature": temperature.value / 100.0, 
    "retrieval_depth": retrieval_depth.value, 
} 
config

runs = [ 
    {"model": "baseline", "accuracy": 0.71, "latency_ms": 220}, 
    {"model": "reranker", "accuracy": 0.78, "latency_ms": 410}, 
    {"model": "hybrid", "accuracy": 0.81, "latency_ms": 530}, 
] 
runs

summary = [ 
    run for run in runs 
    if run["model"] == config["model"] 
] 
summary

Even though the example is intentionally simple, it establishes the correct architecture. Controls define parameter values. A normalization cell turns those values into a stable configuration object. Downstream cells consume that configuration and render results. There are no callbacks, no invisible event chains, and no ad hoc state synchronization. This is the right starting point for AI work because the same pattern scales to more expensive evaluation pipelines.

What belongs in the console and what does not

A disciplined AI experiment console distinguishes interactive orchestration from stable analytical machinery. The interactive layer should expose variables you genuinely need to tune during exploration: model selection, scoring thresholds,

retrieval depth, decoding settings, evaluation cohorts, or error-category filters. The non-interactive layer should contain the parts of the workflow that define experimental consistency: dataset loading, canonical preprocessing, reference labels, baseline metrics, and reusable evaluation kernels.

That separation matters for both reproducibility and reasoning. If you mix preprocessing logic with parameter controls, small UI changes can accidentally redefine the experimental substrate rather than merely changing the current run configuration. If you embed metric definitions directly into render cells, you make it difficult to compare outputs across parameter settings. Treat the console as an orchestration layer over a stable analytical core.

The practical rule is simple: a control should vary a named experimental decision, not rewrite the experiment itself. If a transformation must remain fixed for comparisons to be valid, keep it outside the set of reactive tuning inputs.

Use layers, not a monolith

AI notebooks become brittle when every concern is collapsed into a few oversized cells. A maintainable console uses distinct reactive layers.

Parameter controls: sliders, dropdowns, and selectors that collect user choices.

Normalized configuration: a cell that converts raw UI values into domain parameters.

Execution layer: cells that perform or prepare inference, retrieval, or scoring.

Result tables: structured outputs for runs, examples, and per-slice metrics.

Evaluation summaries: aggregate views, comparisons, and diagnostic renderings.

This structure gives you two benefits immediately. First, it clarifies recomputation. If you change a display option, you should not rerun inference. If you change retrieval

depth, you should expect downstream evaluation artifacts to change. Second, it makes provenance inspectable. You can always tell where a visible result came from: a specific configuration flowed into a specific execution path and produced a specific set of derived artifacts.

This layered design also aligns naturally with reusable code extracted into ordinary Python modules, which belongs elsewhere in the book. The notebook can remain focused on experiment orchestration while domain logic and evaluation kernels live in functions you can test outside the notebook.

Lazy mode is not optional for serious AI workloads

Interactive AI work often involves expensive fan-out. A single change to temperature or retrieval depth may force new model calls, re-ranking, embedding retrieval, or evaluation against many examples. Eager reactivity is pleasant for lightweight summaries, but it can be counterproductive for expensive runs. marimo’s lazy runtime mode is therefore a practical necessity for many AI consoles.

Use eager mode when the affected downstream cells are cheap enough that immediate feedback helps exploration: toggling among precomputed result sets, filtering existing evaluation tables, or updating small comparative summaries. Use lazy mode when interactions would trigger costly inference or scoring. In lazy mode, parameter changes can mark downstream cells stale instead of recomputing them immediately. That gives you a clean control flow for AI work:

Adjust parameters interactively.

Inspect the current configuration and any cheap derived context.

Decide whether the new configuration is worth a full run.

Recompute expensive cells deliberately.

This pattern preserves the benefits of reactive state without letting every micro-adjustment become a resource drain. It also improves judgment. You can make several parameter changes as a coherent experiment move rather than forcing the system to execute half-formed ideas one interaction at a time.

Treat configuration as an explicit experimental artifact

A notebook control value is transient unless you convert it into a durable configuration object and propagate that object explicitly. For AI work, this is more than a style preference. Parameter provenance is central to technical judgment. If you cannot say which values produced a given metric table or failure slice, the console is not serving its purpose.

Your normalized configuration cell should therefore carry exactly the parameters that define a run. Keep that object compact, readable, and stable. Avoid sprinkling direct reads of UI values across many downstream cells. Centralizing configuration gives you one place to inspect the current run definition and one dependency boundary through which all expensive work flows.

A well-shaped configuration cell also helps comparisons. If two outputs differ, you can inspect whether the difference comes from model choice, retrieval depth, temperature, or some other parameter. If every downstream cell reads controls independently, that reasoning becomes harder, and small inconsistencies are easier to miss.

Separate live controls from result history

One subtle failure mode in AI notebooks is confusing the current parameter state with the history of executed runs. The current controls tell you what you are about to evaluate or what a stale downstream cell would use if rerun. A results table should tell you what was actually executed and measured. Those are not the same thing.

Keep them separate. Controls define the active configuration surface. Results tables record run outputs, metrics, or example-level observations associated with completed configurations. If a notebook shows only current controls and current outputs, you lose comparability. If it shows only accumulated results without clear linkage to the current controls, you lose operability.

A practical pattern is to maintain a structured run record in downstream cells: model name, effective parameter values, timestamps or run identifiers managed outside the notebook if needed, and aggregate metrics. The notebook can then use mo.ui.table to inspect these results interactively. Once that table becomes a reactive view, you can drill into particular runs without introducing hidden front-end state.

run_table = mo.ui.table(runs) 
run_table

From there, interactive selection in the results table can narrow the focus to one run or a small comparison set. The exact selection accessor is not confirmed in the retrieved material, so it is best described rather than fabricated: when the table yields a selected subset back to Python, use that subset as the authoritative input for detailed diagnostics, error slices, or example inspection.

Evaluation slices deserve first-class controls

Aggregate accuracy or pass rate is rarely enough for AI evaluation. You often need to inspect slices: difficult prompts, long-context inputs, specific customer cohorts, or error categories. Those slices belong in the console as explicit

reactive inputs rather than as manual filtering steps buried in exploratory code.

This is where the earlier discussion of derived views becomes directly useful. A slice selector can narrow the evaluation dataset before scoring summaries are computed. A results table can then present per-slice behavior, and an interactive subset can feed a details panel for example-level inspection. By keeping slices explicit, you prevent two common mistakes: using a convenient but unrepresentative handful of examples as if they were evidence, and letting ad hoc exploratory filtering redefine the meaning of your metrics.

The design goal is not to expose every possible slice. Expose the ones that materially affect decision-making. Too many slice controls widen the dependency surface and make the notebook cognitively heavy. Too few hide the places where the model actually fails.

Comparative views should branch late

Comparisons are a major reason to build an experiment console at all. You want to contrast models, parameter settings, retrieval strategies, or score thresholds. The temptation is to fork the entire notebook logic early for each variant. That usually produces duplication and drift.

Branch late instead. Keep one canonical representation of configuration, one execution path for a run, and one structured representation of results. Only at the render layer should you branch into a leaderboard table, a failure-example panel, a latency-versus-quality comparison, or a slice-specific summary. This keeps analytical logic centralized and makes it much easier to verify that multiple views are describing the same underlying run artifacts.

Late branching also helps when you add a new output. If you decide to include a comparison chart or a prompt-inspection table, you can build it from the same result records rather than inventing a parallel computation path.

Where chat interfaces fit

The official material verifies synchronized chat interfaces as a class of UI element, but it does not provide stable notebook-side APIs in the retrieved sources for building full LLM evaluation workflows around them. That makes chat interfaces appropriate as exploratory front ends or operator aids, but you should resist treating them as the sole source of experiment state.

If you use a chat-style interaction, bind any important inputs or outputs into explicit notebook values and route those values into the same evaluation structures used elsewhere. Do not rely on transient conversation context alone to define what was tested. The same discipline applies here as with every other control: useful interactivity must resolve into inspectable Python-side state if it is going to support evaluation.

Common anti-patterns

Several mistakes appear repeatedly in AI notebooks that start as demos and grow into consoles.

Binding controls directly to expensive inference. A slider tied straight to a full evaluation pass makes experimentation noisy and costly. Put expensive work behind stable boundaries and use lazy mode when needed.

Mixing prompt construction, execution, and rendering in one cell. That collapses provenance and makes recomputation too coarse. Separate parameterization, execution, and presentation.

Letting example browsing stand in for metrics. Interesting examples matter, but they do not replace structured evaluation. Keep examples tied to slices and runs, not to vague impressions from the latest interaction.

Exposing too many degrees of freedom. A console with dozens of controls may look powerful but can undermine comparability. Prefer a smaller set of parameters that correspond to real experimental choices.

Storing critical state only in the UI. The console is an interface, not a durable experiment tracker. Keep lasting run metadata and artifacts in structured outputs or external logging systems when the work demands it.

A reliable console behaves like an instrument

An effective AI experiment notebook does not try to hide its machinery behind clever UI. It behaves more like a calibrated instrument panel. Controls express a bounded set of experimental choices. A normalized configuration makes those choices explicit. Expensive execution paths sit behind deliberate dependency boundaries. Results flow into tables and summaries that remain comparable across runs. When you inspect a metric change or a failure slice, you can trace it back to a concrete configuration and a concrete run artifact. That is the point at which interactivity starts accelerating judgment rather than eroding it.

Chapter 6
SQL-Centric Analytics in a Python-First Notebook

Many advanced notebook users already know SQL and Python well, yet their combined workflows often degrade into brittle strings, opaque state, and ad hoc query handoffs. This chapter shows how marimo changes that equation: SQL can participate in a reactive, testable notebook architecture, provided you make deliberate choices about where transformations live, how query boundaries are drawn, and which layer owns meaning, interactivity, and performance.

6.1 Choosing SQL’s Role in a Python-First Notebook

A mixed notebook usually drifts into trouble for a simple reason: relational logic starts life as a few filters in Python, then accumulates joins, grouped summaries, ranking, and reshaping until the code no longer reads like Python or SQL. At that point, you are not deciding whether SQL is available. You are deciding which language owns the meaning of the analysis.

A marimo notebook sharpens that decision because the notebook remains a pure Python file while still supporting SQL as a native analytical tool. That combination is powerful, but it removes an excuse many teams rely on. You cannot treat SQL as a separate notebook world with its own hidden runtime model. SQL participates inside a Python-first environment, so the boundary between the two languages has to be intentional.

A small example makes the distinction concrete. Suppose you start with a dataframe and want revenue by region after filtering out small orders. You can express that transformation in Python with chained dataframe operations, or you can express it as a relational query. In a Python-first notebook, both are possible; the better choice depends on where the meaning is clearer and where future maintenance cost is lower.

import duckdb 
import pandas as pd 

orders = pd.DataFrame( 
    { 
        "order_id": [1, 2, 3, 4], 
        "region": ["east", "west", "east", "west"], 
        "amount": [120, 40, 200, 150], 
    } 
) 

summary = duckdb.sql(""" 
    SELECT 
        region, 
        COUNT(*) AS order_count, 
        SUM(amount) AS revenue 
    FROM orders 
    WHERE amount >= 100 
    GROUP BY region 
    ORDER BY revenue DESC 
""").df() 

summary

 region order_count revenue
0 east 2 320
1 west 1 150

That query is short, declarative, and schema-oriented. You can inspect it and answer three critical questions quickly: what rows are included, what grouping level is produced, and what columns leave the transformation. If the same logic were embedded in several chained dataframe operations, the result might still be correct, but the relational intent would be harder to review.

Why SQL often clarifies analytical intent

SQL is strongest when the problem is fundamentally about relations: selecting rows, projecting columns, joining datasets, grouping records, computing windowed statistics, and reshaping a table into another table. Those operations have a stable algebra behind them. When you write them in SQL, you describe what relation you want rather than the exact sequence of row-by-row steps used to build it.

That declarative style matters in notebooks because exploratory work tends to compress multiple concerns into a few cells. A query with explicit SELECT, JOIN, WHERE, and GROUP BY clauses preserves the structure of the data transformation even when the notebook evolves. It also keeps the schema visible. Reviewers can see whether a column is derived, aggregated, or merely passed through. That visibility becomes more important as soon as the notebook serves more than one person.

SQL also maps well to how mixed engineering and analytics teams communicate. Analysts, data engineers, and application engineers can usually read a well-formed query even if they prefer different primary tools. In collaborative settings, this readability is not cosmetic. It lowers review friction, reduces argument about transformation semantics, and makes it easier to compare notebook logic with warehouse logic or dashboard definitions.

Another practical advantage is optimizer leverage. When the transformation is expressed relationally, the execution engine has more room to reorder joins, combine predicates, push filters down, or delay materialization. You should be careful not to promise performance gains automatically, because behavior depends on the engine and data source. Even so, optimizer-friendly expression is a real design signal. If the problem is set-oriented and the engine understands the query as a relation rather than as opaque procedural code, you usually preserve more execution options.

Why Python must remain the dominant outer layer

Even in a SQL-centric analytical notebook, Python remains the dominant architectural layer. marimo notebooks are pure Python files, and its SQL support operates within that model rather than replacing it with a second notebook

language runtime. That means Python still owns orchestration, reusable helpers, control flow, external libraries, testing hooks, model code, and application composition.

Python is the better home for work that is not naturally relational. If you are calling a machine learning library, implementing custom feature extraction, traversing nested objects, applying domain-specific algorithms, or managing notebook-local control decisions, forcing that logic into SQL produces brittle code. SQL can emulate some procedural tasks through complex expressions, common table expressions, or user-defined functions, but the result often becomes harder to test and harder to port.

Python is also where you should centralize the notebook’s durable abstractions. Validation functions, parameter normalization, schema assertions, utility modules, and repeatable plotting helpers belong in Python because they are program structure, not data relation structure. A clean notebook often uses SQL for the tabular core of the analysis while Python supplies the surrounding discipline that keeps the notebook maintainable.

This distinction is especially important in marimo because SQL cells are implemented as Python under the hood. The practical consequence is that SQL is not an isolated language island. Interpolated values, variable scoping, and dependency tracking all matter in ways that look familiar to Python users. If you forget that fact, you can end up treating SQL as a harmless string layer when it is actually part of the notebook’s executable program.

Choose SQL when the transformation is relational

Use SQL as the primary language when the logic has most of the following properties:

The inputs and outputs are tables with stable column semantics.

The core operations are filtering, joining, aggregation, windowing, projection, or reshaping.

The result is easier to describe as a relation than as a sequence of object manipulations.

The transformation benefits from optimizer-aware execution.

You expect reviewers to inspect the logic as tabular business semantics rather than as general-purpose code.

These signals often appear together. A cohort retention query, revenue rollup, customer segmentation summary, event deduplication pass, or top- ranking by partition is almost always more legible in SQL than in ad hoc dataframe code. SQL expresses the grain of the result naturally. It also makes hidden assumptions easier to surface: grouping level, null handling, join cardinality, and ordering scope.

A useful heuristic is to ask whether you can name the result as a relation. If you can say, “this cell produces one row per customer-month with net revenue and prior-month delta,” the transformation is a strong SQL candidate. If the notebook instead needs to iterate through Python objects, branch on irregular conditions, or invoke non-tabular library code at each step, SQL is no longer the natural primary layer.

Choose Python when the task is algorithmic or library-bound

Python should own the logic when the work is fundamentally procedural, object-centric, or dependent on libraries that have no meaningful SQL equivalent. Typical signals include:

The transformation requires custom algorithms or iterative procedures.

The data is not well modeled as flat relations.

You need fine-grained control flow or exception handling.

The notebook relies on scientific, statistical, or machine learning libraries.

The operation is better expressed as reusable functions, modules, or classes.

Feature engineering often illustrates the boundary well. If the feature is a grouped average, count over a time window, or ratio across joined tables, SQL is a strong fit. If the feature requires tokenization, model-based embedding, custom signal processing, or domain-specific simulation, Python should take over. The key is not ideological purity. The key is to keep each language responsible for the work it expresses most clearly.

This also protects you from the opposite anti-pattern: using SQL merely because the data started in tabular form. Many notebooks become awkward because every task is forced through a query, including work that is plainly application logic. You gain little by encoding non-relational behavior in increasingly elaborate SQL expressions if Python could express the same intent in a few testable lines.

Do not confuse convenience with architecture

marimo makes it easy to combine Python and SQL in one notebook, but convenience can hide architectural drift. A few common anti-patterns appear early and compound quickly.

Scattering logic across tiny query fragments. If each cell contains a small SELECT that depends on the last one, the notebook stops presenting coherent analytical stages. Readers have to reconstruct the true relation from a chain of partial edits. Prefer larger, named transformations that correspond to meaningful semantic steps.

Duplicating relational semantics in both SQL and Python. A frequent failure mode is filtering rows in SQL, then applying the same business rule again in pandas or Polars because the author no longer trusts the earlier transformation. This creates silent divergence when one copy changes and the other does not.

Hiding schema assumptions in interpolation. marimo SQL uses f-string-style interpolation by default. That is convenient for parameter insertion, but it means query text can quietly depend on Python variables. If those variables encode table names, predicates, or fragments of expressions rather than bounded values, you lose the ability to inspect the query as a stable artifact. Literal braces also require escaping with doubled braces, which is another sign that interpolation boundaries deserve care.

A small example shows the difference between bounded parameterization and unreadable query assembly:

min_amount = 100 

query = f""" 
SELECT region, SUM(amount) AS revenue 
FROM orders 
WHERE amount >= {min_amount} 
GROUP BY region 
""" 

result = duckdb.sql(query).df()

That pattern is reasonable because min_amount is a simple value with a clear semantic role. By contrast, assembling entire clauses or table identifiers from uncontrolled state quickly makes the query opaque. Treat Python interpolation as a narrow parameter channel, not as a free-form query construction system.

Using SQL to compensate for poorly structured Python. Sometimes a notebook reaches for SQL only because the surrounding Python transformations are unstructured. That is not a vote for SQL; it is a signal to refactor. The analytical language boundary should reflect the nature of the work, not rescue a disorganized

notebook.

Keep the boundary legible

A legible boundary means another reader can answer four questions without executing the entire notebook mentally:

1.

Which inputs are authoritative for a given transformation?

2.

Which language owns the transformation’s meaning?

3.

At what point does the notebook cross from relational logic into procedural or library-specific logic?

4.

Which parameters are data values, and which would alter query semantics?

You maintain that legibility by naming intermediate relations clearly, limiting interpolation to validated values, and resisting the urge to alternate between SQL and dataframe code for equivalent operations. If a filter belongs in the relational stage, keep it there. If formatting belongs near visualization, keep it in Python. Every unnecessary crossing between languages increases cognitive load.

Explicit registration and stable naming also help when you later query local dataframes or external systems. DuckDB, for example, can query certain Python variables by name, which is excellent for fast exploration. But convenience based on ambient variable lookup can blur provenance once the notebook grows. When the notebook must be reproducible and reviewable, stable names and explicit boundaries are safer than magical resolution.

Polars presents a complementary lesson. Its SQL interface can execute against registered frames and keeps query planning lazy. That model encourages you to define named inputs and preserve relational intent without immediately materializing results. The important architectural point is not which engine you choose. It is whether the query boundary is visible and stable enough that another practitioner can reason about it without guessing which in-memory object was captured.

A practical allocation rule

When you face a new piece of notebook logic, classify it before you implement it:

1.

If it changes row membership, grouping grain, join structure, or column derivation across tables, start in SQL.

2.

If it controls execution, validation, error handling, model invocation, or reusable program structure, keep it in Python.

3.

If both are involved, make one side primary and keep the handoff narrow.

The third case is the one that matters most in practice. Many notebook tasks are mixed. A notebook may use SQL to produce a customer-level feature table, then Python to fit a model and score records, then SQL again to aggregate scored outcomes by segment. That is fine because each stage has a clear owner. Trouble starts when the same semantic layer is implemented twice in two languages.

A good handoff usually looks like this: SQL produces a well-named relation with stable columns and documented grain; Python consumes that relation for specialized computation; downstream outputs depend on the result rather than on an interleaving of raw query fragments and dataframe mutations. The notebook remains Python-first because Python composes the system, but SQL is primary where relational meaning is the point.

What to optimize for

You rarely need to optimize for single-language purity. Mixed-language notebooks are normal and often desirable. Optimize instead for semantic clarity, reviewability, and controlled execution boundaries. If SQL makes the business logic easier to inspect, use SQL. If Python makes the algorithm easier to implement and test, use Python. If a boundary is unavoidable, make the crossing explicit and keep it narrow.

That choice becomes durable when each language has a defined job: SQL owns tabular meaning, Python owns program structure, and the notebook stays readable even after the exploratory phase hardens into something other people must trust.

6.2 Querying DataFrames and External Systems with Clear Boundaries

A notebook becomes hard to trust when you can no longer answer three simple questions: where did this table come from, where was this transformation executed, and when did the data become local to the notebook? That ambiguity shows up early. You start with a local dataframe, pull a small slice from a warehouse, join the two, add a few exploratory filters, and hand the result to Python for plotting or modeling. The analysis still works, but ownership has blurred. Some logic belongs to the source system, some belongs to the local relation engine, and some belongs to Python. If you do not mark those boundaries explicitly, later readers have to infer them from incidental details such as variable names and query fragments.

A clear boundary does not require heavy abstraction. It requires a repeatable flow: extract from external systems deliberately, register or name local tabular inputs deliberately, perform relational transformations in one visible layer, and hand off to Python only when the work stops being relational. That flow keeps semantics stable as the notebook evolves from local exploration into something other people will rerun

and review.

A compact DuckDB example shows both the convenience and the risk of in-memory SQL. DuckDB can query a local Pandas dataframe by variable name through replacement scans:

import duckdb 
import pandas as pd 

sales = pd.DataFrame( 
    { 
        "customer_id": [1, 1, 2, 3], 
        "region": ["east", "east", "west", "east"], 
        "amount": [120.0, 80.0, 200.0, 50.0], 
    } 
) 

results = duckdb.sql(""" 
SELECT 
    region, 
    COUNT(*) AS orders, 
    SUM(amount) AS revenue 
FROM sales 
GROUP BY region 
ORDER BY revenue DESC 
""").df() 

results

region orders revenue
0 east 3 250.0
1 west 1 200.0

That pattern is excellent for ad hoc relational work over local data. It is also the point where boundary discipline starts. The query refers to sales as if it were a SQL table, but the name resolves from a Python variable in notebook scope. That is convenient for exploration and dangerous for long-lived analysis, because a variable rename, reassignment, or shadowed name can silently change the object a query reads.

Separate extraction from transformation

The most reliable mixed-data notebook distinguishes four stages:

Source extraction: obtain data from files, databases, warehouses, or APIs.

Relational transformation: apply joins, filters, aggregations, window functions, and projections.

Notebook-local derivation: perform non-relational reshaping, feature generation, statistical computation, or library-specific processing.

Downstream consumption: render tables, train models, export artifacts, or feed visual outputs.

These stages do not always map one-to-one to cells, but they should remain conceptually distinct. Source extraction answers “what data entered the notebook runtime?” Relational transformation answers “what table semantics did you derive?” Notebook-local derivation answers “what extra logic required Python rather than SQL?” Downstream consumption answers “who uses the result and in what form?”

If you merge all four concerns into one or two cells, the notebook becomes operationally opaque. A failed result could come from stale source data, a mistaken join, a local type conversion, or a plotting transformation. By contrast, when each stage has a clear owner, debugging narrows immediately.

Use implicit resolution for exploration, explicit names for durable work

DuckDB’s replacement scans make exploratory analysis pleasant. You can point SQL directly at in-memory Pandas, Polars, or Arrow-backed objects without boilerplate registration. That directness lowers the cost of trying an idea, checking a grouping assumption, or comparing two candidate joins. For one-off analysis, this is often exactly the right trade-off.

For durable notebook logic, however, implicit name resolution can obscure provenance. The problem is not that DuckDB is unclear; the problem is that notebook scope is permissive. A table name that resolves against ambient Python state is harder to audit than a name introduced intentionally for query use.

When the notebook matters enough to review or rerun carefully, move toward explicit registration or explicit relation boundaries. DuckDB supports registration of dataframe or Arrow objects as virtual tables, which makes ownership clearer than relying on globals. The concrete registration API varies by connection context, so if you standardize on that pattern in your own codebase, document the chosen connection lifecycle alongside it. The main architectural rule is straightforward: bind query-visible names deliberately rather than accidentally.

A practical convention works well:

Keep raw extracted data in variables whose names reflect source provenance, such as warehouse_orders or crm_customers.

Introduce stable SQL-facing names when you register or expose them to a query engine.

Reserve relation-producing SQL cells for analytical semantics, not for hidden source discovery.

That naming discipline matters even more when one notebook mixes external data and notebook-local enrichments. If orders sometimes means “warehouse table pulled yesterday” and sometimes means “locally filtered dataframe with derived columns,” you have already lost the boundary.

Push source-specific work toward the source

External systems add a second boundary: execution location. A query that runs against a warehouse, operational database, or remote engine is not equivalent to a query that runs locally over a materialized extract, even when the SQL text looks similar. The source system may have different collation rules, null semantics, type coercions, available functions, permissions, and optimizer behavior. It may also sit behind network latency or access controls that alter how interactive the notebook feels.

Keep source extraction explicit so you can decide what should execute close to the data. Use the external system for operations that benefit from pushdown: large scans, selective filtering, coarse aggregation, or joins that should not require shipping full tables into notebook memory. Materialize into the notebook runtime only when local iteration, mixed-source joining, or Python-side processing justifies the move.

A clean pattern is to extract the narrowest authoritative relation you need from the source, then perform notebook-local analysis on that bounded input. This keeps remote I/O visible and limits the chance of accidental full-table pulls during exploration. It also makes reproducibility easier. When the notebook result changes, you can ask whether the remote extract changed or whether the local transformation changed, rather than untangling both at once.

Use Polars SQL contexts to make mixed inputs explicit

Polars offers a useful contrast because its SQL interface encourages named registration of inputs rather than ambient variable lookup. Its SQLContext can register in-memory frames and file scans under explicit relation names, which is a strong fit when you want clear ownership over what each query can see.

import pandas as pd 
import polars as pl 

sales_data = pd.DataFrame( 
    { 
        "customer_id": [1, 2, 3], 
        "amount": [120.0, 80.0, 200.0], 
    } 
) 

sales = pl.from_pandas(sales_data) 

with pl.SQLContext( 
    sales=sales, 
    regions=pl.scan_csv("regions.csv"), 
    eager=True, 
) as ctx: 
    joined = ctx.execute(""" 
SELECT 
    r.region_name, 
    COUNT(*) AS orders, 
    SUM(amount) AS revenue 
FROM sales AS s 
JOIN regions AS r 
    ON s.customer_id = r.customer_id 
GROUP BY r.region_name 
ORDER BY revenue DESC 
""") 

joined

This pattern does two things well. First, it makes the query-visible schema boundary explicit: the SQL can read sales and regions, and nothing else by accident. Second, it cleanly separates external-system access from local reshaping. The CSV scan remains a named source relation; the Pandas dataframe is converted intentionally with pl.from_pandas(sales_data); the SQL cell owns the relational combination of the two.

Polars also supports broader SQL entry points, including polars.sql() and frame-level .sql() methods, and it documents support for Polars DataFrame, LazyFrame, and Series objects as well as Pandas and PyArrow inputs. The design lesson is less about API preference than about visibility. Explicitly registered contexts make it obvious which relations exist, which names are stable, and where conversion occurred.

Treat materialization as a design choice

A mixed notebook often fails because it materializes at arbitrary points. A remote query becomes a local dataframe because it was convenient. A lazy relation becomes eager because a quick preview was useful. A local intermediate gets converted

between engines repeatedly because several libraries need tabular input. None of these steps is inherently wrong, but each one changes the operational properties of the notebook.

Materialization affects at least five things:

Cost: large remote results can make interactive work slow or memory-heavy.

Determinism: a local snapshot may protect you from source changes during one analysis pass.

Inspectability: a named intermediate can clarify lineage if it corresponds to a semantic stage.

Pushdown opportunity: once you materialize locally, later operations cannot run close to the source.

Type behavior: conversion between engines may alter nullability, string handling, or temporal types.

Materialize when the transition itself is meaningful. Good reasons include freezing a source extract at a known grain, creating a stable handoff to Python libraries, or isolating an expensive source query from many downstream exploratory steps. Weak reasons include convenience, habit, or uncertainty about what the current engine can do.

When you do materialize, name the result according to role rather than implementation. A variable named customer_month_base communicates more than tmp2 or df_joined. Role-based naming helps preserve semantic ownership even if you later switch from one engine to another.

Watch for type drift at language and engine boundaries

Schema ambiguity is one of the most common failure modes in mixed SQL notebooks. DuckDB documentation explicitly calls out Pandas object columns, and that warning deserves attention. A Pandas object column may contain strings, mixed Python objects, or values that require expensive inspection and conversion before the relation engine can reason about them. If you hand such a

dataframe to SQL and assume the engine sees a clean textual or categorical column, you may get surprising coercions, slower execution, or inconsistent null behavior.

The fix is not exotic. Normalize ambiguous columns before you cross the boundary into SQL. Convert textual columns to a consistent string dtype when possible. Resolve mixed-type columns before registration. Make date and timestamp parsing explicit. If a join key originates in Python with inconsistent representation, repair it before the query, not inside several downstream predicates.

A short validation pass often prevents hours of confusion:

import pandas as pd 

events = pd.DataFrame( 
    { 
        "customer_id": [1, 2, 3], 
        "event_type": ["view", "buy", "view"], 
        "amount": [None, 125.0, None], 
    } 
) 

events["event_type"] = events["event_type"].astype("string") 
events["customer_id"] = events["customer_id"].astype("int64")

That code is intentionally mundane. Boundary reliability often comes from small, boring decisions made early. If you leave types ambiguous at the extraction point, every later relational step has to compensate.

Keep dynamic SQL bounded and auditable

Once a notebook mixes local and external data, dynamic SQL becomes tempting. You may need optional filters, selected columns, or source-dependent predicates. Resist the urge to assemble large query fragments from raw strings. The readability cost is immediate, and the auditability cost appears later when you try to prove that two notebook runs used the same semantics.

Prefer bounded query templates with validated Python values inserted into narrow positions. That approach aligns with marimo’s SQL behavior, where SQL cells are represented as Python and interpolation follows f-string rules. It is suitable for values such as thresholds, date bounds, or limited category selections. It is a poor fit for unconstrained clause generation, uncontrolled identifiers, or free-form business logic.

A practical rule helps: interpolate values, not structure. If you must vary structure, keep the set of possible structures small, explicit, and easy to inspect in Python before execution.

Do not interleave remote I/O with every analytical step

A notebook that touches an external system in many small cells becomes unpleasant to use and difficult to reason about. It invites accidental repeated scans, makes latency part of routine editing, and complicates failure handling. It also blurs whether a downstream discrepancy came from changed source data or from a changed local transformation.

Batch remote access into a small number of well-named extraction stages. Then perform most exploratory relational work against those extracted relations locally, or keep it entirely in the source system if locality is the point. What you want to avoid is a notebook where every tiny question requires fresh remote I/O. That pattern scales poorly for both people and infrastructure.

This does not mean every notebook needs a formal data-access layer. It means every maintainable notebook needs visible query boundaries. You should be able to point to the cells or functions that introduce external state, the cells that own relational meaning, and the cells where Python takes over for non-relational

work.

A robust operational pattern

A durable mixed notebook often settles into a simple structure:

Load or scan external data in one place with source-oriented names.

Normalize critical types before exposing the data to SQL.

Register or expose stable relation names explicitly when the notebook moves beyond casual exploration.

Write SQL against those stable names for joins, aggregations, and other relational logic.

Hand off the resulting relation to Python for modeling, statistics, or presentation-specific shaping.

That pattern keeps data location, execution location, and semantic ownership distinct. The notebook remains flexible enough for exploration, but it does not ask readers to reverse-engineer where each table came from or which engine produced it. The resulting analysis is easier to review, easier to rerun, and much less likely to fail silently as the notebook grows into production-adjacent work.

6.3 Designing Cross-Language Reactivity Between SQL and Python

A mixed notebook fails in a subtle way when the cells remain syntactically valid but drift semantically out of sync. You change a threshold that shapes a SQL query, a downstream Python summary still reflects an older result, and the notebook looks orderly even though its meaning has fractured. In a linear notebook model, you are responsible for remembering execution order well enough to repair that state manually. In marimo, dependency-driven reactivity changes that burden. Dependent cells rerun automatically or become stale under

the lazy runtime, which means consistency is managed through a graph of data dependencies rather than through your memory of which cells to re-execute.

That shift matters most when SQL and Python share responsibility for one analytical surface. A SQL cell may interpolate Python values. A Python cell may consume the query result. A UI element may change a parameter that invalidates both. Once those edges exist, the notebook behaves less like a script and more like a dataflow program. The central design problem is no longer just “where should this logic live?” but also “how should change propagate, and what is the cost of keeping the notebook consistent?”

A small example anchors the problem. In marimo, SQL cells are Python-backed, so a Python variable can naturally invalidate a SQL query that uses it. That is convenient and dangerous in equal measure: convenient because the dependency is natural, dangerous because an undisciplined parameter surface can cause broad recomputation.

import duckdb 
import pandas as pd 

orders = pd.DataFrame( 
    { 
        "region": ["east", "east", "west", "west"], 
        "amount": [120, 80, 200, 60], 
    } 
) 

min_amount = 100 

query = f""" 
SELECT 
    region, 
    COUNT(*) AS orders, 
    SUM(amount) AS revenue 
FROM orders 
WHERE amount >= {min_amount} 
GROUP BY region 
ORDER BY revenue DESC 
""" 

summary = duckdb.sql(query).df() 
summary

 region orders revenue
0 west 1 200
1 east 1 120

If min_amount changes, the query result is no longer valid. In marimo’s execution model, that dependency can be tracked directly because the SQL is represented within Python rather than as a disconnected artifact. The main question is how much of the notebook should depend on summary, and whether every one of those dependents should rerun immediately.

Design dependency surfaces before you optimize execution

Cross-language reactivity is easiest to reason about when you classify dependencies explicitly. In mixed SQL-Python notebooks, four dependency types account for most invalidation behavior:

Parameter dependencies: Python values such as dates, thresholds, selected categories, or limits that alter query results.

Data dependencies: SQL result sets that feed Python transforms, model code, or visual outputs.

Structural dependencies: Python choices that alter which table, schema, or source relation a query reads.

Presentation dependencies: downstream formatting or plotting cells that depend on already-derived tabular outputs.

Each dependency type has different cost and risk. Parameter dependencies are usually narrow and desirable. Data dependencies are unavoidable but should be shaped carefully. Structural dependencies deserve caution because they can change semantics more radically than a threshold update. Presentation dependencies are often cheap and can be recomputed aggressively if upstream boundaries are well chosen.

A useful design habit is to ask two questions for every cell: what exact upstream state does this cell depend on, and does it need the full upstream result or only a reduced interface? If you do not answer those questions explicitly, you tend to create wide dependency fans where one query result feeds everything: tables, charts, metrics, exports, and modeling code. Then a trivial UI change propagates much farther than necessary.

This dependency pattern is small enough to understand and rich enough to expose the key design tension. The SQL query is an expensive boundary. Upstream changes should reach it only through a validated parameter surface. Downstream consumers should depend either on the raw query result or on a narrower Python-derived result, but not on both unless the split serves a clear purpose. When you preserve that shape, invalidation remains predictable.

Keep query templates stable and parameters narrow

One of the easiest ways to lose control of reactive behavior is to let query text itself change too often. A stable query template with a few validated parameters produces a clean dependency edge: Python values influence SQL results, but the relational structure remains inspectable. Once you start constructing whole clauses dynamically, the dependency graph becomes harder to reason about because both data values and query semantics are now moving targets.

In marimo, this distinction matters because SQL cells are Python-backed and use f-string-style interpolation. A date bound or threshold is a narrow parameter dependency. A generated list of joins, selected tables, or partial predicates is a structural dependency. Structural dependencies are not forbidden, but they should be treated as higher risk because they alter not only the result but often the shape and cost profile of the query.

You can manage that risk by separating parameter validation from query execution. Put user- or notebook-controlled values through a Python validation step that maps them into a bounded set of legal values before they reach SQL. That gives you a clean dependency chain: raw input → validated parameter → SQL result. It also makes stale marking and rerun behavior easier to interpret because there is a single authoritative representation of the parameter state.

Use lazy propagation to protect expensive boundaries

Automatic reruns are attractive until one parameter change triggers a remote query, a large join, a model-scoring pass, and three charts. marimo’s lazy runtime addresses that operational problem by allowing dependent cells to become stale instead of running immediately. For expensive SQL, remote systems, or wide downstream graphs, this is not a minor optimization. It is a usability feature that preserves consistency without forcing cost on every edit.

The discipline this introduces is subtle. Lazy propagation does not mean you can ignore dependencies; it means you can choose when to pay for consistency restoration. That is especially valuable when a SQL cell hits an external warehouse or performs a heavy local join. You still want the notebook to know that downstream cells are now out of date. You do not necessarily want the notebook to rerun them while you are still adjusting a parameter or refining the query text.

A good practical rule is to treat expensive I/O and large relational transforms as explicit cost boundaries. Build the notebook so that these cells depend only on the smallest set of validated upstream values. Then place cheaper formatting and

visualization work downstream. If the expensive boundary becomes stale, you know exactly which cells are affected and can choose when to refresh them.

Narrow the blast radius of a wide SQL result

A practical failure mode in reactive notebooks is accidental dependency explosion. You produce one broad SQL result with dozens of columns and many rows, then point every downstream cell at it: KPI summaries, descriptive tables, feature-generation code, validation checks, and visual outputs. The graph is technically correct, but operationally clumsy. A small parameter change invalidates everything, even when only one chart or one metric truly needs the updated field.

You can reduce that blast radius with intermediate narrowing steps. Create lightweight Python or SQL-derived relations that expose only the columns and grain each consumer needs. For example, let a heavy query produce a customer-level relation, then derive a tiny summary table for metrics and a separate aggregated series for visualization. The metrics cell should depend on the summary relation, not on the full customer-level result. The chart should depend on the aggregated series, not on every raw column returned from SQL.

This is a form of interface design inside the notebook. Instead of thinking only in terms of cells, think in terms of stable outputs with narrow semantics. A relation used for model features has different dependency needs from a relation used for a single trend line. When you honor that difference, the notebook reruns less work and the graph becomes easier to inspect.

Prefer explicit query contexts when dependency visibility matters

Implicit object lookup can make a notebook feel concise while hiding reactive dependencies. DuckDB replacement scans are a good example. They allow a query to read a Python variable by name, which is excellent for quick work over local dataframes. In a reactive notebook, however, that convenience can make the dependency surface less explicit. If the query reads orders, you must know which Python object orders currently names and which cells can rebind it.

Explicit relation registration or isolated query contexts make those edges easier to reason about. Polars SQLContext is a good fit when you want query-visible inputs to be intentionally named and bounded. Because Polars SQL is planned lazily, it also aligns well with a notebook that wants to defer materialization until a result is actually needed.

import polars as pl 

sales = pl.DataFrame( 
    { 
        "region": ["east", "east", "west"], 
        "amount": [120, 80, 200], 
    } 
) 

min_amount = 100 

with pl.SQLContext( 
    sales=sales, 
    eager=True, 
) as ctx: 
    filtered = ctx.execute(f""" 
    SELECT * 
    FROM sales 
    WHERE amount >= {min_amount} 
    """) 

filtered

Here the dependency surface is legible. The query can read sales because it was registered intentionally in the context. The parameter min_amount is the explicit Python-side driver of invalidation. If you later replace sales with a scanned file or a differently prepared relation, the context boundary still tells readers what the SQL cell can see.

You do not need to use explicit registration everywhere. For small exploratory

notebooks, implicit resolution can be perfectly reasonable. The key is to recognize the threshold where convenience begins to obscure provenance and dependency edges. Once a notebook becomes collaborative, expensive, or operationally important, explicit contexts pay for themselves quickly.

Determinism matters as much as reactivity

A reactive notebook can still be analytically misleading if the upstream SQL is nondeterministic or depends on mutable external state. Automatic reruns guarantee freshness relative to dependencies; they do not guarantee stability of meaning. If a remote table changed between two runs, the notebook may be perfectly reactive and still produce different answers for reasons unrelated to your edit.

The most common determinism hazards are familiar:

Queries without stable ordering when downstream code assumes a particular row order.

Reads from mutable external systems without snapshot or date constraints.

Temporary tables or session-scoped objects whose lifecycle is not obvious in notebook execution.

Structural dependencies that switch sources or schemas based on runtime state.

Reactive design should make these assumptions visible. If a Python cell downstream expects sorted data, sort explicitly in SQL or Python and name that step. If a query depends on a reporting date, represent that date as an explicit parameter rather than as ambient “current” state. If a remote system is mutable, isolate the extract boundary so readers know where temporal instability can enter the graph.

Split cells by semantic cost, not by language alone

A common mistake is to split cells mechanically into “Python cell” and “SQL cell” without considering cost or invalidation semantics. That approach satisfies language separation but often produces poor reactive behavior. Instead, split cells according to semantic and operational boundaries.

A useful pattern is:

One cell defines or validates parameters.

One cell performs expensive SQL or remote extraction.

One or more cells derive narrow, cheap downstream tables.

Final cells handle formatting, metrics, or plots.

This structure gives you precise invalidation behavior. Parameter edits affect the validation cell first. Only validated changes reach the expensive SQL boundary. Cheap downstream derivations can rerun freely when the expensive result changes. Presentation cells remain thin and disposable.

Notice that this pattern does not force all SQL into one place or all Python into another. It forces each cell to correspond to a stable unit of meaning and cost. That alignment matters more than language purity for reactive execution.

Use reactivity to expose, not hide, analytical assumptions

The strongest mixed notebooks make dependency edges explain analytical meaning rather than merely reflect implementation. A threshold parameter should be named for the business rule it represents. A SQL cell should produce a relation with clear grain and schema intent. A Python transform should narrow or enrich that relation in a way that downstream readers can inspect without chasing hidden state through multiple cells.

When you compose SQL and Python this way in marimo, reactivity becomes more than convenience. It becomes a guardrail against semantic drift. Parameter changes invalidate the right queries. Expensive boundaries can remain stale until you are ready to recompute them. Narrow interfaces prevent small edits from detonating the whole notebook. The result is a mixed-language analytical workflow that stays predictable under change, which is exactly what you need once the notebook becomes something more serious than a temporary scratchpad.

6.4 Composing SQL, UI Inputs, and Visual Outputs for Analytical Surfaces

An analytical notebook becomes a different kind of artifact as soon as you add

controls. A slider for a threshold, a dropdown for a segment, and a table or chart for the result can turn an exploratory notebook into a compact analytical application. The benefit is obvious: you can ask many closely related questions without rewriting code. The risk is less obvious: query logic, widget state, and display transformations start to blur into one another, and the notebook stops telling you where analytical meaning actually lives.

The most reliable design keeps three layers separate. UI elements express analyst intent. SQL owns relational meaning such as filtering, joining, grouping, and windowed summaries. Python owns validation, orchestration, and any post-query work that is not naturally relational. If you keep those responsibilities distinct, interactivity improves clarity. If you collapse them, interactivity hides logic.

A grounded example helps. marimo treats UI elements as reactive inputs to Python code, and SQL results hand back Python dataframes that you can feed into tables and plots. That gives you a clean path from control state to validated Python values to relational query to output. The code below uses a slider and a dropdown, validates the grouping dimension in Python, runs SQL with DuckDB over a local dataframe, and exposes the result as a dataframe ready for display.

import marimo as mo 
import duckdb 
import pandas as pd 

sales = pd.DataFrame( 
    { 
        "region": ["east", "east", "west", "west"], 
        "channel": ["web", "store", "web", "store"], 
        "amount": [120, 80, 200, 60], 
    } 
) 

min_amount = mo.ui.slider(start=0, stop=250, value=100) 
group_by = mo.ui.dropdown( 
    options=["region", "channel"], 
    value="region", 
) 

allowed_dimensions = { 
    "region": "region", 
    "channel": "channel", 
} 
dimension = allowed_dimensions[group_by.value] 

query = f""" 
SELECT 
    {dimension} AS dimension, 
    COUNT(*) AS orders, 
    SUM(amount) AS revenue 
FROM sales 
WHERE amount >= {min_amount.value} 
GROUP BY {dimension} 
ORDER BY revenue DESC 
""" 

summary = duckdb.sql(query).df() 

controls = mo.ui.dictionary( 
    { 
        "min_amount": min_amount, 
        "group_by": group_by, 
    } 
) 

controls, summary

That pattern encodes an important architectural rule. The UI does not directly express SQL. It produces values. Python validates those values and maps them into a bounded analytical vocabulary. SQL then uses that bounded vocabulary to express the relational transformation. The result is a stable tabular handoff point into tables, plots, or other views.

Treat UI state as intent, not as query text

A control should answer a domain question such as “minimum order amount,” “group by region or channel,” or “selected month.” It should not encode arbitrary SQL fragments. Once a widget’s value is allowed to behave like free-form query structure, you lose nearly every advantage of a notebook as an inspectable analytical artifact. Readers can no longer tell whether a result changed because the user selected a different threshold or because the widget smuggled in a different join path or predicate structure.

This is why bounded templates matter. A slider naturally emits a numeric value. A dropdown naturally emits one element from a fixed set. Those are excellent reactive inputs because they have obvious meaning, limited range, and predictable downstream effect. By contrast, a text input that gets spliced into a WHERE clause or ORDER BY expression is almost always too unconstrained for maintainable notebook logic.

You can enforce this discipline with Python-side whitelists. In the example above, allowed_dimensions maps the dropdown value onto a small set of legal SQL identifiers. That is more than an input-sanitization habit. It is a way to preserve analytical readability. A reviewer can inspect the whitelist and know exactly which grouping semantics the notebook allows.

Keep the layering model explicit

When a notebook behaves like an analytical surface, you should be able to identify the role of each cell immediately:

UI cells collect analyst intent.

Validation cells normalize and constrain values.

SQL cells express relational meaning.

Python output cells format, visualize, or enrich the result.

That separation gives you two practical benefits. First, reactivity becomes easier to reason about because each layer has a narrow dependency surface. Second, logic remains inspectable because no single cell has to both interpret user intent and perform business transformations.

marimo’s compositional helpers make that organization easier to maintain when the number of controls grows. You can use mo.ui.dictionary, mo.ui.array, or mo.ui.batch to group related controls into coherent UI structures while still preserving a single downstream source of truth in Python. The point is not that you must use these helpers everywhere. The point is that dynamic control collections should still collapse into a small, named set of values before they reach SQL.

A compact pattern using mo.ui.array is useful when multiple filters share the same semantics:

import marimo as mo 

thresholds = mo.ui.array( 
    [ 
        mo.ui.slider(start=0, stop=500, value=50), 
        mo.ui.slider(start=0, stop=500, value=100), 
    ] 
) 

selected_thresholds = [x.value for x in thresholds.value] 
selected_thresholds

You would rarely feed selected_thresholds directly into raw SQL. Instead, you would interpret it in Python, decide which bounded query pattern it activates, and then compose the relational step from that decision. This keeps UI composition ergonomic without letting dynamic collections dissolve the SQL boundary.

Use SQL for relational filtering, not for every interactive effect

Once UI controls are available, it is tempting to push every interaction into SQL. That is often the right choice when the interaction changes row membership, grouping grain, or aggregate meaning. If a date range alters which facts belong in the

analysis, SQL should usually own that filter because the predicate is part of relational semantics.

Not every interaction should cross that boundary. Some controls affect only presentation: sort order in a displayed table, chart color theme, whether to show confidence intervals, or which metric from an already-computed summary to place on the y-axis. Pushing those choices back into SQL creates unnecessary query churn and increases notebook latency without improving analytical clarity.

A useful decision rule is to ask whether the control changes the meaning of the relation or only the view of the relation. Changes in relation meaning belong upstream, usually in SQL. Changes in view belong downstream, usually in Python. This rule keeps interactive notebooks responsive and makes lineage easier to follow.

Choose between three interactive strategies deliberately

Most analytical surfaces fall into one of three strategies for interactivity.

Query-time filtering. Controls directly influence SQL predicates, grouping, or window definitions. This gives you maximum freshness and flexibility because every control can reshape the result at the source of relational meaning. The cost is latency and potentially higher backend load, especially if the query touches a remote system.

Pre-aggregated or precomputed views. Controls select among already-prepared relations or summaries. This yields fast interaction and stable performance, but only for questions anticipated in advance. The notebook gains responsiveness at the cost of a narrower question space.

Python-side filtering over local results. Controls adjust views or slice an already-materialized dataframe after the main query has run. This feels agile and can be appropriate for small local tables, but it easily hides assumptions about data volume and can duplicate semantics that really belong in SQL.

None of these strategies is universally correct. Choose based on interaction frequency, data volume, backend cost, and how much you need each control to preserve visible lineage. High-frequency controls over expensive remote queries often justify pre-aggregation or a local summary layer. Low-frequency controls that materially change the business definition of the result should usually stay in SQL.

Validate before you interpolate

Because marimo SQL behavior is Python-backed and follows f-string-style interpolation rules, you need a clear policy for what can cross from UI state into query text. The safest default is simple: interpolate validated values into bounded templates, and never interpolate unconstrained structure.

A robust pattern is to normalize all UI values in Python before the SQL cell sees them. For example, if a dropdown chooses a metric, map the UI value onto a limited dictionary of approved expressions or identifiers. If a date range arrives from two controls, validate that the start precedes the end and that both fall inside the notebook’s supported domain. If a control is optional, map the absence of a value onto an explicit query branch rather than constructing fragments ad hoc.

That pattern is especially important when multiple controls interact. Two individually reasonable controls can create confusing semantics when combined. A date range and a grouping selector may be harmless alone but ambiguous together if some groups lack complete temporal coverage. Python is the right place to validate that combination and either adjust the query path or communicate the constraint to the user.

Keep outputs dependent on stable tabular interfaces

A common anti-pattern is to let each chart or table re-express part of the business logic independently. One cell plots revenue by month after applying a local filter. Another table recomputes a slightly different aggregation. A third metric card uses a separate SQL fragment for “just the total.” The notebook may look polished, but each output now owns its own partial semantics.

Instead, produce a stable result table or a small number of stable result tables, then let outputs consume those interfaces. Since SQL results in marimo return as Python dataframes, they form a natural handoff point. One dataframe can feed a table viewer, a plotting library, and a summary statistic cell without re-encoding the transformation each time. This does not eliminate all downstream shaping, but it confines that shaping to presentation concerns.

This discipline also improves debugging. If a chart looks wrong, you can inspect the dataframe feeding it. If the dataframe is wrong, you know the defect lies in the query or its parameters. Without that handoff point, every output becomes a possible source of analytical divergence.

Use code visibility strategically

Interactive notebooks can become opaque when readers see only the controls and outputs but not the transformation logic. marimo provides mo.show_code, which is useful when you want the notebook to act like a compact application without hiding how it works. The goal is not to expose every internal helper at all times. It is to ensure that analytical meaning remains inspectable.

For notebooks shared within a technical team, code visibility often matters more than UI polish. A small, well-labeled control panel plus visible query and transformation logic usually beats a highly polished surface that requires readers to reverse-engineer semantics from output behavior alone. If you decide to hide parts of the implementation for presentation reasons, keep the hidden boundaries simple and ensure the visible cells still reveal where values come from and where SQL owns meaning.

Avoid duplicated filter semantics

The fastest way to corrupt an analytical surface is to apply the same business rule in multiple places. A slider controls minimum amount in SQL, and then the chart cell applies another local filter “just to be safe.” A category dropdown narrows the query, but a table view silently filters out null categories in Python. These are not harmless redundancies. They create the possibility that outputs disagree while each still appears plausible.

You avoid this by assigning each semantic rule one owner. If the rule determines which rows belong in the analysis, SQL owns it. If the rule determines how the already-derived result is displayed, Python owns it. Do not let the same rule appear

in both layers unless the duplication is deliberate, documented, and mechanically checked.

Compose multiple controls without scattering logic

As notebooks become more application-like, the control surface often expands: date bounds, category selection, threshold, metric choice, region filter, normalization toggle, and comparison mode. The danger is not the number of controls itself. The danger is scattering their interpretation across many cells.

Group controls physically for usability, but interpret them centrally. A single Python cell that reads .value from the relevant controls and builds a normalized parameter object is much easier to inspect than six cells that each partially interpret one widget. With marimo’s UI collection helpers, you can keep the interface organized while still converging on one source of validated parameters.

That normalized parameter object should become the only upstream dependency for the SQL layer. Once you do that, the reactive graph becomes easy to reason about: controls change values, Python validates and normalizes them, SQL computes the relation, and outputs consume the result.

Operationalize the surface without changing its semantics

One of the practical strengths of marimo is that the same notebook can be edited interactively and then run in app mode. A straightforward workflow looks like this:

marimo edit notebook.py

marimo run notebook.py

That operational symmetry matters for analytical surfaces. You do not need one artifact for exploration and another for sharing. The same Python file can serve both roles if the internal boundaries are clean. Standard Python execution remains appropriate when you want script-like behavior, while app mode is useful when the notebook is intended to be interacted with through its controls.

This makes boundary discipline even more important. Once a notebook is shareable, every hidden query fragment, duplicated filter, and overly clever UI coupling becomes a maintenance problem for someone else. A notebook that behaves like a concise domain-specific application should still read like an honest analytical program: controls express questions, Python validates intent, SQL defines tabular meaning, and the outputs stay tethered to a small set of explicit dataframes.

Chapter 7
From Notebook to Shareable Application

A marimo notebook can be the beginning of a workflow, or it can be the product itself. This chapter explores the moment where exploratory code meets real users, showing that the hardest part is rarely getting an app to run once; it is choosing the right distribution form, controlling recomputation and state under interaction, and understanding the deployment limits imposed by security, runtime assumptions, and the realities of a fast-moving 0.x toolchain.

7.1 Choosing Between Notebook, Script, and App Modes

A marimo notebook can cross several boundaries without changing file format. The same .py artifact can stay in an authoring workflow, run under ordinary Python for automation, or become a user-facing application. That flexibility is useful, but it also makes the first delivery decision easy to get wrong. Teams often start from what is technically possible and only later notice that they picked the wrong operational surface: analysts are forced into code they should not edit, batch jobs depend on UI-oriented state, or a stakeholder dashboard is shipped as an executable notebook.

You avoid most of that rework by choosing the execution surface from user intent rather than from convenience. In marimo, the mode split is explicit:

marimo edit analysis.py 
python analysis.py 
marimo run analysis.py

These three commands express three different contracts. marimo edit preserves the notebook as an authoring environment. python analysis.py executes the notebook as a script, which is the right fit when shell semantics, side effects, and automation matter. marimo run analysis.py serves the notebook as an app, presenting outputs and controls to consumers instead of an editing interface. If you treat them as cosmetic variants of the same thing, you inherit the wrong assumptions about mutability, observability, and support.

Start with the audience, not the artifact. The fastest way to classify a notebook is to ask who controls inputs and who is expected to understand the code. That single distinction separates authoring from delivery.

If the primary user is you or another technical collaborator who needs to inspect cells, revise logic, and experiment with intermediate results, keep the notebook in edit mode. Notebook mode is not merely “interactive” in the general sense; it is an authoring surface. It assumes the user may alter code, inspect dependencies, and navigate the analysis structure directly. That makes it the most powerful mode and also the least constrained. It is excellent for exploration, model iteration, debugging, and technical review. It is poor for guided consumption because it exposes far more state and freedom than a downstream user usually needs.

If the user is not expected to edit code but does need to trigger a defined computation with well-understood inputs, script mode is usually the better boundary. Here the notebook behaves like a Python program. Standard output goes to the terminal, side effects such as writing files or emitting logs fit naturally, and the run can be orchestrated by shell scripts, schedulers, CI jobs, or other automation. The notebook remains a marimo file, but operationally it is now a command-line program.

If the user should interact through controls and outputs, while the code remains hidden or at least secondary, app mode is the right boundary. In that case the notebook itself is the product surface. The consumer is not reading a computational narrative or maintaining the analysis; they are operating a constrained interface built on top of the reactive graph.

Mode choice is a product-boundary decision. The distinction among notebook, script, and app modes is deeper than presentation. Each mode defines what counts as normal behavior.

In notebook mode, normal behavior includes local iteration, rerunning cells while developing, inspecting intermediate values, and restructuring the analysis. Latency is tolerated differently because the author understands why a recomputation occurred. Observability is high because code, outputs, and dependencies are visible. Support burden is relatively low because the users are also the maintainers or close collaborators.

In script mode, normal behavior is deterministic execution from a command invocation. The run either succeeds, fails, or emits diagnostics that another system can capture. A script can still perform complex reactive computation internally, but that is no longer the user-facing mental model. The user expects command-line semantics, reproducibility by rerun, and integration with ordinary Python tooling.

In app mode, normal behavior is interactive consumption without free-form editing. The consumer expects stable controls, bounded interaction paths, and a visible surface organized around outcomes rather than code structure. Every reactive dependency that is exposed through a widget becomes part of the product contract. If a slider movement triggers an expensive query, that cost is now part of the app’s usability profile, not just an authoring detail.

Use notebook mode when authoring context is the value. Notebook mode is the correct choice when the work product is still under active construction or when consumers need the full computational narrative. That includes exploratory analysis, research iteration, model diagnostics, technical review, and collaborative editing among people who can read and modify Python.

marimo’s notebook model matters here because the notebook is stored as a pure Python file and executed through a reactive dependency graph. The mechanics of dependency inference and stale-state behavior belong elsewhere; what matters for mode selection is that edit mode is the one place where those mechanics remain directly legible to the user. If a collaborator needs to inspect why an output changed, compare alternative cells, or restructure data preparation logic, do not hide the notebook inside an app.

Notebook mode also fits cases where the boundary between reusable module code and exploratory code is still moving. If you are still deciding what should become a function, what should become a parameter, and what should remain ad hoc analysis, freezing the notebook into an app is premature. Likewise, if a workflow still depends on author-only diagnostics, local probing, or iterative plotting, script mode will feel too opaque and app mode too restrictive.

The failure mode is pushing non-authors into notebook mode because it is the easiest thing to share. That exports your working context, not a product. Business users rarely benefit from seeing setup cells, imports, intermediate data wrangling, or experimental branches. They inherit cognitive load without gaining control.

Use script mode when execution itself is the product. Script mode is the cleanest choice when the important event is “run this computation” rather than “interact with this interface.” You should prefer it for scheduled reports, CI validation, file generation, data preparation, model scoring jobs, and shell-driven workflows.

A simple validation path looks like this:

marimo check report_job.py 
python report_job.py

The preflight check is useful before automation because it catches notebook issues early, while direct Python execution preserves normal script semantics. This is especially important when the notebook has side effects such as writing artifacts to disk, producing machine-readable output, or logging progress to stdout and stderr. Those behaviors align naturally with script execution and can become awkward when forced through an app surface.

Script mode also tends to minimize maintenance cost when the notebook must integrate with an existing codebase. A scheduler, Make target, CI job, or shell pipeline can invoke python report_job.py without caring that the file originated as a notebook. The same artifact can often import shared modules, parse command-line arguments through normal Python patterns, and participate in repository tooling like tests and linters. That makes script mode a strong fit when the notebook is converging toward operational software.

Another good signal is session duration. If the execution is finite, produces durable outputs, and does not require an ongoing user session, script mode is often sufficient. A nightly report generator does not become better software by adding a served interface. It becomes harder to operate because now you have to manage an application boundary for a task that was fundamentally batch-oriented.

The most common anti-pattern here is using app mode for work that only needs parameterized execution. If an analyst wants to choose a date range and generate a report once, you may not need a live app; you may need a script with explicit inputs and a documented invocation path. Adding a UI can obscure failure handling, logging, and artifact generation while delivering little user

value.

Use app mode when guided interaction is the value. App mode becomes the right answer when the notebook’s value depends on exposed controls and constrained exploration by non-authors. The exact command is straightforward:

marimo run dashboard.py

Running a notebook this way starts a web server and presents the notebook as an application. By default, code is hidden and outputs are concatenated into an app-oriented layout. That default matters because it encodes the intended audience: app mode is for users who should operate the notebook, not maintain it.

Choose app mode when all of the following are mostly true:

users need interactive recomputation rather than a one-time batch result;

the interaction can be expressed through a bounded set of controls;

code inspection is unnecessary or undesirable for the target audience;

the session has value while it is live, not only after artifacts are produced.

Typical examples include parameterized analytical dashboards, internal decision-support tools, scenario exploration interfaces, and stakeholder-facing visualizations where users adjust inputs and immediately observe updated outputs.

App mode carries a stronger support contract than notebook or script mode. When you ship an app, users expect stable defaults, understandable control names, bounded latency, and a coherent visible layout. They do not expect to debug your code or recover from exploratory dead ends. That means app mode is not simply a way to hide notebook cells. It is a commitment to a narrower and more intentional user experience.

Ask five questions before you pick a mode. A reliable decision framework can be expressed as a short sequence of technical questions.

Who owns the inputs? If the same person who maintains the code also chooses inputs interactively while developing, notebook mode is natural. If another

system or scheduled process owns the inputs, script mode is usually cleaner. If downstream users own the inputs through a curated interface, app mode fits.

Who may inspect or edit code? If code visibility is required, stay in notebook mode or expose a script. If code visibility is irrelevant or distracting, app mode gives a better boundary. The wrong choice here often creates a governance problem: either too much implementation detail leaks outward, or maintainers lose the transparency they need.

What is the consumer actually trying to obtain? Outputs only suggests export or scripted generation, not necessarily an app. Parameterized execution with machine-oriented results often points to script mode. Interactive recomputation with human-in-the-loop interpretation points to app mode.

How long does the interaction live? Short-lived, finite runs with durable artifacts favor script mode. Ongoing, exploratory, user-driven sessions favor notebook or app mode depending on whether code editing is part of the workflow.

What kind of reproducibility do you need? If rerunning a program from a defined environment is enough, script mode is usually sufficient. If the delivered experience depends on a live reactive surface where users change controls and observe updates, app mode is the relevant contract. If reproducibility requires preserving the full analytical workspace and its editable reasoning path, notebook mode remains appropriate.

Operational constraints are often the real deciding factor. Two modes may both be technically feasible, yet only one is operationally sane. That is where maintenance cost becomes visible.

Notebook mode has the lowest packaging pressure but the highest exposure of implementation detail. It assumes a technical audience and a development-friendly environment. That is cheap for authors and expensive for broad consumption.

Script mode is usually the cheapest to automate and monitor. It works well with standard process supervision, return codes, logs, and file-based outputs. If the workload depends on local files, external services, credentials, or heavyweight native packages, script mode often preserves those assumptions more directly than browser- or UI-centric delivery surfaces.

App mode offers the best guided experience for non-authors, but it expands the support surface. You now own interaction design, runtime responsiveness, user-visible error handling, and the tension between reactive power and operational cost. If an expensive computation sits behind an innocuous control, app mode exposes that cost repeatedly. If the notebook depends on author-local resources, app mode makes those hidden assumptions user-facing failures.

A useful rule is to prefer the least powerful surface that still matches user intent. Every step from notebook to script to app narrows one kind of flexibility and expands another kind of obligation.

Common misclassifications and their cost. Several mistakes appear repeatedly in teams adopting marimo for shared work.

Shipping an app when a batch artifact is enough. This happens when authors equate “shareable” with “interactive.” If the consumer only needs a refreshed

report or a generated dataset, app mode adds server lifecycle, UI maintenance, and reactive latency without improving the outcome.

Forcing stakeholders into notebook mode. This is the mirror-image mistake. The notebook is easy to hand over, but the recipient now sees imports, setup logic, temporary cells, and implementation scaffolding. Support requests rise because the surface was never designed for consumption.

Embedding automation inside a UI-first artifact. A notebook may contain widgets and beautiful interactive outputs, yet still be the wrong place to host a nightly pipeline. Automation needs predictable invocation, logging, and side-effect handling. Script mode is designed for that contract; app mode is not.

Choosing browser-oriented interaction for workloads that need server-local resources. A notebook that relies on local files, secure connectors, or large server-resident dependencies should not be classified as a lightweight user app just because it has controls. The computational boundary matters more than the presence of widgets.

A practical selection heuristic. When you are uncertain, classify the notebook by the narrowest successful user story.

If the user story is “I need to edit and reason about the analysis,” choose notebook mode. If the user story is “I need to run this reliably and capture outputs or side effects,” choose script mode. If the user story is “I need to operate a curated interface and see results update,” choose app mode.

You can also test the decision by asking what failure would be most surprising. In notebook mode, a user unable to inspect code is a failure. In script mode, a run that cannot be invoked cleanly from automation is a failure. In app mode, a user who must understand the notebook’s internal structure to complete a task is a failure. That framing exposes mismatches early, before you build deployment machinery around the wrong surface.

marimo makes it easy to move among these modes because the notebook remains a Python file. That portability is most valuable when you use it to sharpen boundaries instead of blurring them: author in notebook mode, execute in script mode when the workflow becomes operational, and present as an app only when guided interaction is genuinely the product.

7.2 Serving Reactive Notebooks as Apps

A notebook becomes an application the moment someone else relies on its reactive behavior without seeing the authoring context. A control that felt harmless during local exploration now defines a user-visible contract: change an input, recompute the right cells, and refresh outputs without exposing accidental complexity. That shift is not about hiding code; it is about turning a reactive program into a stable interaction surface.

You can serve a marimo notebook as an app with a single command:

marimo run dashboard.py

This starts a web server and renders the notebook as an application instead of an editor. That change in surface area is the key technical event. In edit mode, you can tolerate rough edges because the author understands the graph, the intermediate cells, and the cost of reruns. In app mode, the user sees only controls and results. Every dependency edge that can be triggered through the UI becomes part of the delivered runtime behavior.

The serving command is simple; the runtime contract is not. The directness of marimo run can obscure what you are really shipping. You are not exporting a static view. You are serving a live reactive program. Inputs participate in the notebook’s dependency graph, dependent cells rerun or become stale depending on runtime configuration, and outputs update according to those semantics. If your notebook has one expensive branch coupled to several cheap outputs, a user action can trigger more work than the visible surface suggests.

That is why app preparation starts by separating author-only cells from user-facing cells. Keep diagnostics, scratch computations, one-off debugging helpers, and local inspection code out of the delivered surface. The reactive graph still determines execution, so visible simplicity alone is not enough; you also need dependency simplicity. If a hidden exploratory cell still sits on the hot path of a common interaction, users pay for it even when they never see it.

Authoring a notebook for app use. A notebook intended for app serving benefits from a deliberate top-level structure. Start with controls and essential derived values. Place expensive computations behind clear parameter boundaries. Keep outputs close to the cells that define their user-facing meaning. Treat the notebook less like a lab notebook and more like a reactive view model.

You usually want three classes of cells:

Input cells that define widgets, parameter choices, and lightweight validation.

Computation cells that transform inputs into derived data, models, or query results.

Presentation cells that render tables, text, and plots for the user.

That partition is not stylistic. It helps you reason about what should rerun when a user touches a control. If the input layer contains hidden I/O, or if presentation cells perform heavyweight computation, interaction latency becomes hard to predict. The reactive engine will still do the correct thing from a dependency standpoint, but the user experience will feel arbitrary.

When you harden a notebook for app mode, remove anything that assumes a developer is present. Common examples include debug prints, local-path probes,

informal assertions that are useful only during editing, and cells whose purpose is to inspect intermediate state. If those concerns still matter operationally, route them into logs or external monitoring instead of the visible notebook surface.

Default rerun behavior versus lazy runtime. Reactive notebooks are powerful because users do not have to manage execution order manually. The trade-off is that every input change can propagate through a substantial graph. marimo’s runtime configuration controls how aggressively that propagation happens. Under the default behavior, dependent cells rerun when an upstream value changes. Under lazy runtime, descendants are marked stale until needed. If a downstream cell is executed while one of its ancestors is stale, marimo first runs the stale ancestors so the user does not observe outputs computed from stale inputs.

Those semantics matter in app mode because they translate directly into interaction cost. Suppose a slider controls a threshold used by three cheap summary cells and one expensive model-fit cell. Under eager rerun behavior, each slider move can trigger the whole dependent chain. Under lazy behavior, the notebook can defer some recomputation until the affected output is actually needed. That does not eliminate cost; it changes when the user pays it and which interactions feel immediate.

You should choose between these behaviors based on the app’s cost profile, not by habit. If users expect continuous direct manipulation, avoid wiring a high-frequency control into a heavyweight computation path unless the dependency structure is intentionally constrained. If users interact in larger discrete steps, stale marking can be a useful way to prevent unnecessary recomputation while preserving correctness.

What a user session actually does. A served app session is easier to design once you think in terms of event flow instead of notebook cells. The user performs an action. That action changes a value bound into the reactive graph. marimo identifies the affected dependents. Depending on runtime configuration, it reruns them immediately or marks them stale. When the needed computations complete, the visible outputs refresh.

That sequence has several practical consequences.

First, controls are not just UI elements; they are reactive entry points. A date selector tied to a database-backed aggregation is an execution trigger. A dropdown that chooses among preloaded arrays is much cheaper than one that selects among remote queries. To the user, both are single clicks. To the runtime, they can differ by orders of magnitude in cost.

Second, the visible app layout can hide dependency coupling. Two charts placed far apart may share a heavy upstream preparation step. If a user changes a filter near one chart and the other chart rerenders too, that may be logically correct and still feel surprising. Good app design in marimo therefore requires dependency design, not only layout design.

Third, session expectations must be explicit in your mental model. Distinguish derived state from persistent side effects. Derived state belongs naturally in the reactive graph. Persistent side effects such as writing files, mutating external systems,

or sending notifications require more discipline because users can trigger recomputation more than once. What feels safe during private notebook work can become dangerous in a served app if a control change repeats an external action.

Idempotence matters more in app mode. A private notebook can tolerate occasional side-effectful cells because the author understands when they run. A served app should assume users will click more than once, revisit parameter combinations, and explore edge cases. If a cell writes to disk, inserts rows into a database, or calls an external API with side effects, recomputation semantics become operational risk.

Favor idempotent computations in the reactive path. If a user interaction must eventually trigger a side effect, isolate that action conceptually and operationally. Keep it out of the same dependency chain as ordinary derived outputs whenever possible. Even when a side effect is technically correct on rerun, repeated invocation can still produce confusing user behavior, duplicate artifacts, or avoidable load on upstream systems.

This is also why app mode deserves stricter defaults than notebook mode for filesystem and credential assumptions. A notebook author may implicitly know that a CSV exists at a local relative path or that a cloud credential has already been loaded into the shell. App users do not know that, and the app surface rarely communicates it clearly. If those assumptions remain hidden, failures appear random from the user’s perspective.

Serving one notebook versus serving a gallery. marimo can serve more than one notebook at a time. For a directory of notebooks, or for a mixed list of files and folders, use commands like these:

marimo run apps/ 
marimo run sales.py forecast.py apps/

This capability is useful when you need a small internal gallery of related tools. Technically, however, a gallery is not just a convenience wrapper around multiple apps. It also creates a product boundary at the collection level. Users now navigate among artifacts, compare behaviors, and infer consistency across them. If one notebook behaves like a polished app and another still exposes exploratory assumptions, the mismatch becomes obvious.

Treat a gallery as a curated set of app contracts. Align naming, layout expectations, latency tolerance, and error messaging across the notebooks you serve together. Otherwise you move friction from “how do I launch this notebook” to “why do these tools behave differently even though they appear to belong together.”

Operational launch details that affect real deployments. Local serving is often enough during development, but the moment you place the app inside a container or behind a host boundary, explicit network binding matters. The documented pattern is:

marimo run app.py --host 0.0.0.0 -p 8080

The –host 0.0.0.0 binding is the operationally important part when the process runs in Docker or another isolated environment. Without it, the service may only listen on a loopback interface that external clients cannot reach. Port selection is similarly not cosmetic; it becomes part of your deployment contract with proxies, platform routing, and health checks.

A minimal container workflow also benefits from ordinary process hygiene: run as a non-root user, keep the environment explicit, and make file and secret dependencies visible in configuration rather than implicit in the image. marimo app serving does not remove any of the usual responsibilities of operating a Python web process. It simply gives that process a reactive notebook runtime instead of a hand-built web UI stack.

Security boundaries are narrower than many teams assume. marimo includes token- and password-based protection, which is helpful for internal sharing and controlled access during development. It is not a full authentication and authorization platform. If your deployment needs identity integration, role-based access, organizational policy enforcement, or richer request handling, place the app behind ASGI middleware or external authentication layers.

This distinction matters because notebook-derived apps often begin as internal tools and then acquire broader audiences. A random token in edit mode and a deterministic token in run mode may be enough for quick protection, but they do not answer questions about who may access which data, how sessions are audited, or how authorization changes over time. Those concerns sit outside the notebook itself.

Keep the security model aligned with the app’s data sensitivity and operational lifetime. An internal demonstration tool and a long-lived analytics application may share the same marimo run entry point, yet demand very different control planes around it.

A hardening pass before you call it an app. Before serving a notebook to real users, run a short but disciplined review.

First, simplify the visible surface. Remove author-only cells, tighten labels, and choose defaults that produce useful outputs immediately. A blank or confusing first render makes reactive power irrelevant.

Second, inspect the dependency graph mentally through common interactions. Change each major control and ask which expensive cells will rerun, which outputs will refresh, and whether that behavior will make sense to a user who cannot see the graph.

Third, validate state assumptions. Inputs should not rely on hidden local files, undeclared services, or ambient credentials unless you are prepared to operate those dependencies deliberately.

Fourth, test failure behavior. If a query fails, a file is missing, or a computation takes too long, the app should degrade in a way that preserves user understanding. A raw traceback may be acceptable during internal development, but it is rarely a stable user-facing contract.

Fifth, check the mode-specific runtime expectation. Startup autorun settings are relevant to editing workflows, not to the served app contract. What

matters in app mode is how the live runtime behaves under actual user interactions.

Choosing app mode commits you to interaction design under reactive semantics. That commitment is where most notebook-to-app efforts either become elegant or become fragile. When the controls map cleanly onto computation, when expensive branches are isolated, and when the reactive graph aligns with user expectations, marimo app serving gives you a remarkably direct path from analytical code to a working interface. When those boundaries remain accidental, the app still runs, but every interaction leaks the notebook’s unfinished structure. The difference is rarely in the launch command; it is in how intentionally you expose reactivity to someone who did not author the notebook.

7.3 Exporting and Sharing Browser-Oriented Artifacts

A live app is not always the right delivery surface. Many teams need something easier to circulate than a running service: a report that can be archived, an interactive artifact that can be posted to static hosting, or a precomputed snapshot that fits into CI and review workflows. The practical question is not whether marimo can share a notebook, but which exported artifact preserves the part of the experience your audience actually needs.

A useful starting point is to export the same notebook in several ways and compare what survives:

marimo export html notebook.py 
marimo export html-wasm notebook.py \ 
 -o wasm_out --mode run 
marimo export session notebook.py

These commands create three very different products. marimo export html produces a non-interactive HTML snapshot. marimo export html-wasm produces an interactive browser artifact that runs without a local Python or marimo installation, subject to browser-runtime constraints. marimo export session writes a JSON session artifact, which is a separate class of output intended for serialized state rather than direct browser presentation. They are related only in the loose sense that they all originate from the same notebook.

Export is a packaging decision, not a fallback for app serving. Once you decide not to serve a continuously running backend, you are choosing among packaging strategies with different execution boundaries. A static HTML export moves all computation to export time. A WebAssembly-backed browser artifact moves execution into the browser at view time. A session export records notebook state in a serialized form that other workflows can consume. Those boundaries determine portability, infrastructure needs, reproducibility expectations, and what can fail in the target environment.

That distinction matters because many sharing mistakes come from collapsing all non-server options into a single idea of “offline sharing.” A static snapshot is excellent

when readers only need outputs. It is a poor choice if they must change parameters. A browser-runnable artifact feels interactive, but it inherits the browser’s runtime limits and package-compatibility constraints. A session file is operationally useful, yet it is not a substitute for a human-facing report or app.

Static HTML is a presentation artifact. Use static HTML when the notebook’s value lies in its rendered outputs, narrative structure, and visual presentation rather than in ongoing interaction. The command is intentionally direct:

marimo export html notebook.py

The resulting HTML is non-interactive. That should shape both your expectations and your notebook design. If a chart, table, or textual summary only needs to be reviewed, emailed, attached to documentation, or archived as a point-in-time result, static export is often the lowest-friction option. It removes nearly all runtime assumptions from the reader’s side. They do not need Python, a server, or knowledge of the notebook’s internal structure.

This format works especially well for review and communication workflows. A generated HTML snapshot is stable enough for design review, model review, or report distribution because viewers all see the same rendered state. That is a strength when you need consistency, but a limitation when the original notebook depended on user-controlled parameters. If the notebook’s meaning changes materially with different input values, a static artifact is closer to a screenshot of one valid state than to a reusable analytical tool.

You should also treat static export as a disclosure decision. Whatever outputs are rendered at export time become part of the artifact. If the notebook contains sensitive intermediate tables, author-only diagnostics, or data slices that were acceptable in a local workflow but not in broad distribution, static HTML will faithfully preserve them. The browser cannot hide what has already been baked into the output.

WebAssembly HTML preserves interaction, but changes the runtime boundary. When users need browser-side interactivity without a continuously served Python backend, html-wasm is the relevant export class. A concrete export and local validation loop looks like this:

marimo export html-wasm notebook.py \ 
 -o wasm_out --mode run --no-show-code 

cd wasm_out 
python -m http.server

This produces an interactive browser artifact. You can also export with –mode edit if the goal is an editable browser notebook rather than an app-like run surface. The export supports options including –mode, –output, –show-code/–no-show-code, –watch/–no-watch, and –include-cloudflare. Those options are not decoration; they define what kind of browser experience you are packaging and how self-contained it will be.

The most important operational fact is that WebAssembly export does not behave like a single self-sufficient HTML file that you can double-click from a

filesystem. The artifact must be served over HTTP, and the assets directory must remain alongside the exported HTML. Opening the file directly via file:// does not work correctly. That means browser delivery is lower friction than running a server-backed app, but it is not zero-friction. You still have to package and host the artifact coherently.

The second crucial fact is package compatibility. Many packages work in WebAssembly notebooks, but not all do. That makes compatibility a first-order export decision, not a late-stage testing detail. A notebook that depends on unsupported packages, native extensions, large local binaries, or server-local resources may run perfectly in app mode and fail conceptually as a browser artifact even before you reach UI concerns. If the computation assumes unrestricted Python execution or direct access to system resources, moving it into the browser changes the platform too radically.

Choose browser interactivity only when the browser can honestly host the work. An exported WebAssembly notebook is strongest when the computation is self-contained, the dependencies are browser-compatible, and the audience values easy sharing more than perfect parity with a server-backed runtime. Small analytical tools, lightweight simulations, educational notebooks, and documentation-oriented interactive examples often fit this profile well.

It becomes fragile when the notebook relies on secure credentials, private databases, local files on the author’s machine, large datasets that should remain server-side, or resource-intensive libraries that the browser runtime cannot support. In those situations, WebAssembly export can create a misleading impression of portability. The artifact may load and still fail to provide the intended experience because the real dependencies were never browser-portable.

That is why the right comparison is not “interactive versus non-interactive” but “where does execution live, and what assumptions survive there?” Server-backed apps preserve full server-side Python capability. WebAssembly artifacts trade that capability for easier distribution. Static HTML removes runtime entirely and preserves only results. Once you frame exports around execution location, the trade-offs become much easier to reason about.

A small publish pipeline clarifies the delivery model. For browser-first sharing, a practical path is to export, validate locally over HTTP, and then publish the output directory to static hosting. A compact workflow looks like this:

marimo export html-wasm notebook.py \ 
 -o site --mode run --include-cloudflare 

cd site 
python -m http.server

Local validation matters because it catches two common mistakes immediately: forgetting that the artifact must be served over HTTP, and forgetting that the assets directory is part of the deliverable. If the notebook behaves correctly under a simple local HTTP server, you can move the same directory to static hosting with much higher confidence.

The –include-cloudflare option is relevant when your publication path benefits from that packaging choice. Even then, you should treat static hosting as a deployment target for browser assets, not as a magic compatibility layer. Hosting can

solve reachability and distribution; it cannot make unsupported packages or server-only assumptions work inside the browser.

Session export is a different artifact class. marimo export session solves a different problem:

marimo export session notebook.py

By default, this writes JSON under __marimo__/session/<notebook>.json. You can also export sessions for an entire directory. This output is not primarily for end-user browsing. It is best understood as serialized notebook state that supports automation, transfer, inspection, or precomputation workflows.

That difference is easy to miss because session export often appears next to HTML export in command lists, yet it serves different consumers. A reviewer reading a report wants HTML. A browser user wanting direct interaction wants a live app or a WebAssembly artifact. A build system, cache-like workflow, or CI job may want a session snapshot because it captures run state in a machine-friendly form.

One useful pattern is to separate expensive computation from final presentation. Run the notebook in automation, export a session artifact, and use that serialized state as part of a controlled downstream process. Another pattern is validation in CI: generate the session output and compare its existence, structure, or freshness as part of a reproducibility check. Those are operational uses, not substitutes for a user-facing analytical interface.

Export strategy depends on what must remain live. A simple diagnostic question helps: when the recipient opens the artifact, what must still be capable of happening?

If the answer is “nothing; they only need to read the outputs,” export static HTML. If the answer is “they must manipulate controls and rerun compatible computations inside the browser,” export html-wasm. If the answer is “another tool or workflow needs serialized notebook state,” export a session. If the answer is “the notebook must retain full Python capability, secrets, connectors, or server-side resources,” do not force the problem into an export format that cannot preserve those assumptions.

This framing also helps with cost control. Static exports are cheap to distribute and stable to archive. Browser-interactive exports reduce backend cost but can increase packaging and compatibility work. Session exports are cheap to automate but useless as a human-facing product unless another layer consumes them. Treat each format as an answer to a specific runtime question, not as interchangeable outputs.

Dependency packaging changes the portability story. Because marimo notebooks are pure Python files, you can also make the notebook itself easier to travel. One approach is to rely on external environment files such as requirements.txt. Another is to inline dependencies directly into the notebook by using marimo’s sandbox support, which records package names and versions in the file using PEP 723-style metadata.

A concrete authoring flow is:

uvx marimo edit --sandbox my_notebook.py 
uv run my_notebook.py

When you run with –sandbox, marimo tracks used package names and versions in the notebook file and executes the notebook in an isolated virtual environment. That makes a single .py notebook more portable because the dependency intent travels with the artifact instead of living only in repository-level packaging.

This does not replace thoughtful export selection. Inline dependencies help another Python-capable environment reproduce notebook execution, whether in edit mode, script mode, or some export pipeline. They do not make an unsupported package suddenly browser-compatible in WebAssembly, and they do not remove the need to separate server-only assumptions from browser-oriented delivery. Still, for lightweight sharing among technical collaborators, a self-describing notebook file can be much easier to hand off than a notebook plus loosely synchronized environment files.

Anti-patterns that make exports brittle. Several mistakes recur when teams first adopt browser-oriented sharing.

Treating static HTML as if it were a lightweight app. A rendered report cannot honor new user inputs. If recipients expect live recomputation, they will misread a snapshot as a broken application rather than as a fixed-state artifact.

Advertising browser artifacts as equivalent to server-backed apps. An html-wasm export can be highly capable, but it runs under browser and package constraints. If your message to users ignores those limits, you create false operational promises.

Forgetting the hosting model for WebAssembly output. If you hand someone a directory and tell them to open the HTML file directly, the artifact fails for a reason that feels arbitrary unless you understand the HTTP and asset requirements.

Assuming export removes environment design work. Static HTML removes runtime for viewers, but the export process still runs somewhere. WebAssembly removes the need for a Python server at view time, but only

after you have established a compatible build and packaging path. Session export reduces friction for automation, yet it still assumes the notebook can run correctly in the export environment.

Using browser exports for notebooks that depend on secrets or privileged connectors. If the notebook’s core value depends on server-side authority, the browser is the wrong place to execute it.

Set expectations in the artifact itself. A well-chosen export still benefits from explicit communication. If you publish static HTML, label it as a snapshot with its generation time or parameter context. If you publish a WebAssembly artifact, indicate whether code is shown, whether the experience is app-like or editable, and whether package or data constraints limit fidelity. If you generate session snapshots for internal automation, document that they are machine-oriented artifacts, not end-user reports.

That small amount of framing prevents users from projecting the wrong mental model onto the artifact. Most dissatisfaction with notebook sharing comes less from missing features than from mismatched expectations about what kind of object was delivered.

Choose the lightest artifact that preserves the required behavior. That rule keeps export strategy honest. If the notebook’s value is already captured in rendered outputs, static HTML is usually the strongest answer because it is stable, portable, and easy to review. If guided interaction must remain live but the browser can legitimately host the runtime, html-wasm is the right tool. If the goal is operational state transfer rather than human consumption, session export fits better than any presentation format. Once you anchor the decision in execution location, runtime assumptions, and audience expectations, browser-oriented sharing becomes a disciplined packaging choice rather than a vague attempt to make a notebook “deployable.”

7.4 Operating Shared Marimo Artifacts Under Real-World Constraints

A notebook that runs perfectly on your machine can still fail as a shared artifact for reasons that have nothing to do with syntax or reactive correctness. The real breakpoints appear after you hand it to someone else: the browser cannot host the required packages, the app depends on implicit credentials, the container listens only on localhost, the session cache does not refresh when you expect, or a public URL exposes an access model that was never designed. Those failures are usually boundary failures. The code works. The operating assumptions do not.

A concrete contrast makes the stakes clear:

marimo run app.py --host 0.0.0.0 -p 8080 
marimo export html-wasm app.py -o out --mode run 
marimo export session app.py

All three commands produce something shareable, but they rely on different trust models, runtime environments, and support obligations. marimo run preserves full server-side Python capability, which is powerful when your notebook needs local files, private networks, secure connectors, or native packages. It also means you now operate a running service. html-wasm removes the server dependency at view time, but only by moving execution into the browser, where not all packages work and filesystem-style opening is unsupported. export session creates a machine-oriented artifact whose operational behavior can surprise you if you treat it like a forced-refresh export rather than a managed state snapshot.

Start with runtime boundaries, not with feature checklists. A shared marimo artifact always runs somewhere: on a Python host, inside a browser, or within an automation environment that interprets exported state. That location determines what resources exist, what security model applies, and how failures manifest.

Server-backed app serving keeps the richest execution environment. If your notebook depends on private datasets, network-reachable services, system libraries, or organization-managed credentials, marimo run is often the only honest choice because it preserves the same broad Python capability as your local environment. The cost is operational responsibility: process supervision, ingress, authentication, resource isolation, and ongoing maintenance.

Browser-oriented export makes a different trade. You remove the need for a Python server for viewers, which is often excellent for lightweight sharing, but you accept browser/runtime/package constraints. The browser cannot act as a transparent substitute for a server. If the notebook’s logic assumes unrestricted filesystem access, native extensions, or protected infrastructure, a browser artifact is not merely inconvenient; it is the wrong runtime.

Session export serves yet another boundary. It is useful for automation, state transfer, and precomputed workflow stages, but it should not be mistaken for either a live app or a human-facing presentation artifact. Once you identify which runtime boundary your audience actually needs, many supposed deployment choices disappear.

Environment drift is the quietest operational failure. Most shared notebook failures are not dramatic. They are subtle mismatches between the author’s environment and the execution environment of the artifact. A package minor release changes behavior, a Python version differs, a local data file is missing, or the notebook assumes a credential helper that was never documented. Because marimo notebooks are ordinary Python files, they inherit all the usual environment-drift risks of Python applications.

Dependency reproducibility is strongest when you use sandboxed execution and inline metadata. marimo’s –sandbox mode records both the dependency set and the Python-version requirement directly in the notebook file. That does not eliminate all reproducibility work, but it makes the artifact more self-describing and reduces the chance that a shared notebook silently relies on an undocumented external

environment.

A practical workflow looks like this:

uvx marimo edit --sandbox analysis.py 
uv run analysis.py

This approach matters even if your final delivery surface is not a script. A notebook that can be reproduced reliably in a clean sandbox is easier to serve, easier to export, and easier to validate in CI. Without that discipline, many support incidents become indistinguishable from application bugs: the user reports that the app fails, but the root cause is really dependency ambiguity.

Inline metadata also changes the maintenance burden across machines. If you hand a notebook to another engineer, a repository-level requirements.txt may still work, but it assumes that the environment file and the notebook remain synchronized. Embedding the requirements in the notebook narrows that gap. It is especially helpful when notebooks travel independently of the repository that produced them.

Security begins with execution authority. The first security question is not whether the notebook has a login form or token. It is what authority the artifact holds when it runs. Can it read files? Reach internal services? Use cloud credentials? Trigger side effects? A notebook-derived app inherits the privileges of the environment in which it executes, and that can be broader than the visible UI suggests.

Built-in token protection is intentionally basic. For limited internal sharing, that may be enough to prevent casual access. It is not a substitute for a full authentication and authorization layer. If you expose a shared marimo app to a broader audience, rely on external ASGI middleware or surrounding infrastructure for real authn/authz. That is where identity integration, access control policy, auditing, and organizational security requirements belong.

This distinction matters most when a tool evolves from a quick internal utility into something that others depend on. A deterministic token and a reachable port may be adequate for a controlled demonstration, but they do not answer questions such as which users may access which notebooks, whether access should be revoked centrally, or how to enforce data-specific permissions. Once the notebook becomes a service boundary, surrounding infrastructure must take over the parts marimo does not claim to be.

Remote code is still code execution. Shared notebook workflows often involve URLs, copied artifacts, and convenience launch paths. Treat any notebook retrieved from a URL as executable code from an external source. marimo’s guidance is explicit on this point: running a notebook from a URL executes remote code and should only be done for trusted sources.

That warning is easy to underestimate because notebooks are often perceived as documents. In operational terms, they are programs. A notebook fetched from a remote location can import packages, access files available to the runtime, make network calls, and perform any action the surrounding environment permits. The fact that the source arrived through a notebook-sharing workflow does not reduce that

risk.

For team processes, this means you should apply the same provenance controls you would use for scripts or application code. Prefer reviewed repositories, pinned references, controlled artifact registries, and trusted distribution channels over ad hoc URL execution. If your organization allows externally sourced notebooks, make the trust boundary explicit and isolate execution environments accordingly.

Containers solve packaging, not design mistakes. Containerizing a marimo app is often the right move, but it only solves a subset of the operational problem. A container can stabilize the Python runtime, bundle dependencies, and provide a consistent launch surface. It does not fix poor mode selection, accidental filesystem assumptions, or security gaps.

The documented launch pattern is straightforward:

marimo run app.py --host 0.0.0.0 -p 8080

In a container, bind to 0.0.0.0, expose the intended port explicitly, and run the process as a non-root user. Those details are not bureaucratic hardening theater. They determine whether the app is reachable from outside the container, whether the runtime respects least privilege, and whether your deployment maps cleanly onto platform networking.

A common failure mode is to build a clean container around a notebook that still assumes an author-local filesystem layout. The app launches, the port responds, and then user interaction fails because a relative path points to a nonexistent file. Another common failure is to rely on ambient secrets baked into the developer shell and then discover that the container runtime has no equivalent configuration. Containers reduce drift, but they make hidden assumptions more visible rather than less important.

Browser artifacts shift risk; they do not remove it. WebAssembly exports are attractive because they appear to reduce infrastructure burden. They do, but only by moving execution into a constrained browser runtime. Operationally, that means your risks change shape.

Package compatibility becomes a release blocker instead of a deployment detail. Asset completeness becomes part of artifact integrity. If you email only the exported HTML and forget the assets directory, the artifact is broken even though the notebook was exported successfully. If a recipient opens the HTML with file:// instead of over HTTP, the failure can look mysterious unless you understand that filesystem-style opening is unsupported.

A realistic failure sequence looks like this: an engineer exports a WebAssembly notebook, attaches only the top-level HTML to an email, and assumes the recipient can double-click it. The recipient sees a broken page. Nothing is wrong with marimo’s export. The delivery package violated the runtime contract. That is exactly the kind of operational failure that a deployment review should catch before distribution.

The same caution applies to security. Browser execution reduces server exposure in some cases, but it also means the browser becomes the execution environment. If

the notebook was only safe because secrets lived on the server or private data was never shipped to the client, a browser artifact may be fundamentally incompatible with the security model.

Session snapshots have their own lifecycle semantics. Session export is particularly easy to misuse in automation because its behavior is more workflow-oriented than many teams expect. By default, session-export operations skip up-to-date artifacts, continue across failures, and only exit non-zero after processing all targets. That behavior is sensible for large batch operations, but it can surprise you if you assumed every invocation forces regeneration or fails fast on the first problem.

For CI and scheduled processes, this has two implications. First, you need to decide whether “up-to-date” semantics match your reproducibility goals. If the notebook code and inline metadata have not changed, skipping regeneration may be desirable because it avoids unnecessary work. If your process expects fresh output every run regardless of change detection, you need to account for that difference in orchestration rather than assuming export commands behave like unconditional rebuilds.

Second, failure handling must be read in aggregate. A non-zero exit after all targets are processed means the command can partially succeed. That may be exactly what you want in bulk maintenance, but it is not the same as a transactional build step. If downstream stages assume that all session artifacts were refreshed uniformly, partial success can produce difficult-to-debug inconsistencies.

In practice, session export works best when you treat it as an incremental artifact-management operation. It is strong in CI pipelines, precomputation workflows, and cache-like state transfer, provided you respect its skip and error semantics instead of treating it like a naive one-shot export.

Version volatility changes how you operationalize confidence. On a fast-moving 0.x release line, you should assume that exact CLI surfaces, defaults, and edge-case behavior can evolve. That does not mean the tool is unstable in a casual sense; it means you must make documentation and release validation part of your operating discipline. When a workflow depends on a specific command flag, export behavior, or serving default, recheck it against current docs during manuscript finalization and before standardizing internal processes.

This is most important for anything user-visible or infrastructure-coupled: app serving flags, export capabilities, browser-runtime assumptions, and packaging features such as sandbox execution. Small changes in defaults can become large changes in operations when many teams or users depend on them. Confidence should come from pinning what you deploy, testing the exact mode you share, and avoiding informal guarantees built on remembered behavior.

A healthy team posture is to separate stable concepts from version-sensitive surfaces. Concepts such as “browser artifacts have browser constraints” or “shared apps need external auth for serious deployments” are durable. Exact command options and current defaults are not durable enough to rely on from

memory.

A deployment-readiness review catches most avoidable failures. Before you call a notebook-derived artifact shareable, walk through a short set of operational questions.

Runtime boundary: Where does execution occur, and does that environment actually provide the files, packages, services, and compute budget the notebook assumes?

Exposure boundary: Who can reach the artifact, and is built-in protection sufficient for that audience? If not, what external authn/authz layer enforces access?

Dependency boundary: Can another machine reproduce execution from declared requirements, preferably through sandboxed metadata, or does success still depend on ambient setup?

Artifact boundary: If you export, are you shipping every required component, such as the assets directory for WebAssembly output, and are recipients instructed to open it in a supported way?

Automation boundary: If you use session export or other generated state, do your CI and scheduled jobs understand skip behavior, partial failure behavior, and what counts as freshness?

Version boundary: Have you verified the exact command surfaces and defaults you rely on against the release line you actually deploy?

Most notebook-sharing problems are not deep framework failures. They are mismatches between a computational artifact and the operational world into which it is introduced. Once you force those boundaries into the open, a shareable marimo notebook stops being a hopeful demo and starts behaving like software you can trust.

Chapter 8
Migration, Environments, and Advanced Project Patterns

Most notebook migrations fail not because the tool is unfamiliar, but because the old mental model keeps leaking into the new one. This chapter makes the transition explicit: first by replacing execution-order intuition with reactive, deterministic reasoning, then by turning conversion, environment control, and repository structure into deliberate operational choices that support durable data and AI work.

8.1 Rewiring the Jupyter Mental Model

A notebook can feel correct for hours and still be untrustworthy. You run a few cells, inspect a plot, tweak a parameter, rerun one analysis block, and everything appears consistent. Then you restart the process, hand the notebook to a teammate, or execute it from top to bottom in a clean session and the result changes or fails outright. That failure is not usually a sign of carelessness. It is the natural consequence of an execution model built around manual ordering, long-lived process state, and outputs that can temporarily drift away from the code that produced them.

A quick way to feel the difference is to open a notebook as a program rather than as a live transcript. marimo makes that shift visible immediately because it treats the notebook as a Python file with tracked dependencies among cells.

marimo edit analysis.py

If you change a definition that other cells depend on, marimo does not leave the rest of the notebook in a quietly inconsistent state. It either reruns dependent cells automatically or, if autorun is disabled, marks them stale. That behavior is the center of the mental reset. You no longer manage trust by remembering what you ran. You manage trust by expressing dataflow clearly enough that the runtime can keep the notebook honest.

From execution order to dependency reasoning

In a traditional order-driven notebook workflow, document order and execution order are loosely related. A cell near the bottom may rely on a value created hours earlier in a different position. You may insert a new definition after downstream analysis has already run, and the notebook will happily preserve old outputs until you choose to rerun. Expertise in that environment often means learning to reason about invisible kernel history: which variables exist, which ones were overwritten, which outputs are stale, and whether a displayed result still corresponds to the current source.

marimo asks you to reason differently. The important structure is not the visual stack of cells on the page but the dependency graph implied by variable definitions and references. A cell that defines a value becomes an upstream

node. A cell that uses that value becomes downstream. Once you accept that model, several behaviors that initially feel strict start to read as correctness guarantees.

A familiar failure mode

Consider the kind of workflow many experienced notebook users perform without thinking: define a dataset, derive a filtered frame, build a model, then return to the top and quietly change the filter logic. In an order-driven notebook, downstream results remain as they were until you manually rerun them, and even then you must remember the right sequence. In a reactive notebook, the changed definition invalidates everything that depends on it. The system now treats your notebook less like a scratchpad of commands and more like an executable graph.

That changes the operational question from “What have I run?” to “What depends on this?” The second question is much easier to answer mechanically, which is why it scales better to review, reuse, and long-lived maintenance.

What assumptions stop working

Several habits from REPL-style notebook work become unreliable or actively incompatible once dependencies are first-class.

Running cells out of order as a normal control mechanism. In Jupyter, running out of order is a common strategy for fast iteration. In a dependency-driven notebook, execution order is no longer your primary coordination tool. You can still edit cells in any order, but correctness depends on declared dataflow, not on your memory of which cells executed last.

Redefining the same symbol in multiple places. marimo builds its graph from variable declarations and references. That model depends on a single unambiguous definition for a given name. If you previously relied on redefining df, model, or config across several cells as a way to evolve a session interactively, you need a different pattern. Use distinct names for distinct states, or refactor the transformation into a function whose inputs and outputs make the state transition explicit.

Treating visible output as proof of current correctness. In an order-dependent notebook, a rendered table or plot often becomes a psychological anchor: if it looks right, you proceed. But output can outlive the code path that generated it. marimo’s stale marking makes that mismatch explicit. A displayed artifact is trustworthy only if the cells that

feed it are current with respect to the dependency graph.

Using kernel memory as a persistence layer. Experienced users often preserve expensive intermediate objects in memory and rely on them across many edits. That works until a restart, a review, or a reproducibility check. In a reactive model, you should distinguish between recomputable state, cached state, and externalized state. If an object is costly enough that recomputation matters, model that cost intentionally rather than relying on the accidental continuity of a long-running session.

Using mutation across distant cells as communication. A shared dictionary updated in one place and read somewhere else is easy to write and hard to reason about. Once cell relationships matter semantically, hidden mutation becomes a source of graph opacity. The notebook may know that a cell references state, but it cannot make your intent clear if every downstream result depends on a mutable object whose contents change over time in nonlocal ways.

What becomes safer

The payoff for giving up those habits is not abstract purity. It is a much stronger safety model for everyday work.

Edits have visible consequences. When you change an upstream definition, you do not need to remember every affected cell. The system tracks the relationship and either propagates recomputation or records staleness. That sharply reduces the classic notebook bug where an analysis appears coherent only because some cells were never rerun after a key change.

Restarts become less dramatic. In a hidden-state workflow, restarting the process is often a disruptive event because it reveals how much of the notebook depended on session history. In a dependency-driven workflow, restart is closer to a routine validation step. If the notebook’s logic is expressed as explicit dataflow, a clean run should reproduce the same semantics without requiring you to reconstruct a particular execution narrative.

Review becomes code review instead of archaeology. When a teammate reads a marimo notebook, they can reason from source dependencies rather than infer intent from output ordering and execution counters. The pure-Python file format helps here, but the deeper advantage is semantic: the notebook’s behavior is constrained by the graph rather than by undocumented session history.

Reactive does not mean indiscriminate reruns

A common migration fear is that reactive execution means every edit causes the whole notebook to run again. That is not the right model. marimo tracks dependencies and responds to invalidation along those edges. If you change a cell that defines a plotting label, only cells that depend on that label become affected. If you change a data-loading path or a preprocessing transformation, the affected frontier is larger because more of the graph depends on that value.

This difference matters operationally. A good notebook in marimo is not one with the fewest cells; it is one whose cells capture meaningful dataflow boundaries. If you collapse half your analysis into a single monolithic cell, every small change forces a large invalidation region. If you split logic at natural dependency boundaries, recomputation stays local and understandable.

That is why reactive execution rewards structure rather than punishing interactivity. You still iterate quickly, but the speed comes from graph locality and clear dependencies, not from exploiting ambiguous process state.

Cell boundaries become semantic boundaries

Many users coming from Jupyter treat cells mainly as visual grouping. One cell for imports, one giant cell for cleaning, another for models, another for plots. In marimo, a cell boundary carries a stronger meaning: it defines a unit of dependency tracking. That should change how you cut the notebook.

Create a cell when a value becomes worth naming and reusing. Split a cell when two computations change at different rates or have different costs. Keep inexpensive pure transformations separate from expensive I/O or side effects. Avoid boundaries that exist only for presentation while hiding meaningful internal state transitions inside one large block.

You can evaluate a cell boundary with a practical question: if this line changed, would you want the same downstream invalidation as every other line in the cell? If the answer is no, the cell is probably doing too much.

Determinism has a boundary

A reactive notebook improves determinism, but not every source of nondeterminism disappears automatically. The graph can track dependencies among cells; it cannot turn impure operations into pure ones.

Random number generation, current timestamps, external APIs, file system mutation, network access, database state, and environment variables still sit at the edge of reproducibility. Treat those as the notebook’s determinism boundary. If a cell performs one of these operations, make that impurity explicit and isolate

it. Bind its result to a named value, keep the side effect local, and avoid mixing it with downstream transformations that could otherwise remain pure.

This is where prior discussion of deterministic execution and reproducibility matters operationally. The notebook runtime can eliminate hidden notebook state, but you still need to design around hidden world state. A clean graph cannot rescue a cell that silently depends on whatever happened to be in a remote table at the moment you ran it.

Stale state is a feature, not an inconvenience

When autorun is disabled, marimo marks downstream cells stale rather than pretending they are still valid. That can initially feel like friction, especially if you are used to temporarily inconsistent notebooks during exploration. Resist the urge to read staleness as interruption. It is an integrity signal.

A stale mark tells you that the notebook currently contains a divergence between code and derived results. In Jupyter, that divergence often remains invisible until much later. In marimo, it is surfaced immediately. Advanced users often gain the most from this because they are the ones most likely to make rapid, nonlocal edits while mentally tracking the consequences. Offloading that bookkeeping to the runtime is a real productivity improvement.

The exact user-facing details of autorun and stale handling can evolve across marimo releases, but the core semantic contract remains stable: downstream validity is tied to dependency changes, not to the appearance of a still-rendered output.

How expertise changes

The skill profile shifts in a useful way. In a REPL-oriented notebook environment, expertise often means tolerating ambiguity: you know when the kernel can be trusted, when a restart is necessary, and how to navigate accidental state. In marimo, expertise shifts toward graph design.

You become better by choosing stable names, isolating side effects, minimizing broad invalidation, and making dependencies obvious. You notice when a mutable object is carrying too much hidden coordination. You recognize when an expensive computation should be given its own boundary. You stop using the notebook as a place to accumulate unnamed intermediate states and start using it as a place to expose them deliberately.

That is a stricter discipline, but it is also a more transferable one. A well-structured reactive notebook behaves more like maintainable software and less like a transcript of a private session.

Migration anti-patterns

Several migration mistakes appear repeatedly when experienced notebook users first adopt marimo.

Recreating a long imperative script across many cells. If each cell mutates a shared global object and the notebook only makes sense

when read top to bottom, you have preserved the old model inside a new runtime. The notebook may execute, but you will fight the graph instead of benefiting from it.

Using globals as an event bus. A dictionary or list that many cells mutate becomes a hidden coordination channel. Prefer immutable-looking flows: define a value, transform it, bind the result to a new name, and let dependencies express the lineage.

Embedding expensive side effects in otherwise pure cells. If one cell both downloads data and computes five downstream-ready derived tables, every small change risks retriggering a costly operation or forcing awkward workarounds. Separate acquisition from transformation so the dependency graph reflects operational cost.

Interpreting reactive behavior as wasteful. When a dependent cell reruns after an upstream change, that is not busywork. It is the runtime preserving consistency. If reruns feel too broad, the usual problem is not reactivity itself but poorly chosen boundaries or overly coupled state.

Fighting unique definitions. If you repeatedly want to redefine the same name in different cells, that is usually a sign that the notebook is modeling temporal session states rather than stable program structure. Use separate names for alternative results, or move the changing logic into functions and parameterized expressions.

A practical rewrite instinct

When migration friction appears, ask a small set of questions before touching tooling. Does each cell define a coherent value? Are mutable objects crossing too many boundaries? Are expensive effects isolated from cheap transformations? Does any displayed result depend on a value whose provenance is ambiguous? Is a name being reused to mean multiple different states over time?

Those questions usually reveal the real issue faster than trying to force the old workflow into the new model. You are not losing interactivity; you are replacing manual state choreography with explicit program structure. Once that clicks, everyday notebook actions change meaning. Editing a cell becomes a graph update. Rerunning becomes dependency propagation. A stale result becomes a

correctness warning. A clean restart becomes a routine validation rather than a destructive reset. That is the point where a notebook stops being a fragile session artifact and starts behaving like a reactive program you can trust.

8.2 Converting Existing Notebooks and Handling Caveats

A successful conversion is not the moment a command finishes without error. It is the moment a notebook can be executed from a clean process, reviewed as ordinary Python, and trusted to produce the intended results without relying on old kernel history. That difference matters because automated conversion can preserve syntax and cell boundaries, but it cannot infer the invisible habits that made a legacy notebook appear correct during interactive use.

Start with one notebook, not a repository-wide bulk rewrite. Pick a notebook that matters, classify its risk, convert it, and validate it from a fresh process before you establish any team-wide migration pattern.

marimo convert analysis.ipynb -o analysis.py 
marimo edit analysis.py

That short sequence gives you a concrete artifact to inspect: a Python-backed notebook whose behavior now has to survive reactive execution rules, unique definitions, and clean reruns. If the original notebook depended on execution accidents, the conversion will expose the problem quickly, which is exactly what you want.

Triage before conversion

Legacy notebooks usually fall into four operational categories.

Straightforward notebooks are mostly linear, define values clearly, and do not rely heavily on preserved kernel state. These usually convert well and require only modest cleanup.

Notebooks with mild state leakage contain repeated variable reassignment, scattered imports, or a few cells that were clearly rerun out of order during exploration. These are still good migration candidates, but you should expect to split cells and rename values.

Notebooks with substantial hidden-state dependence use global mutation across distant cells, depend on previous sessions, preserve expensive intermediates only in memory, or rely on outputs as a surrogate for stored results. Conversion may succeed syntactically while leaving a semantically broken notebook.

Notebooks that should be refactored instead of directly migrated mix credentials, ad hoc utilities, data access, plotting, model training, and environment fixes into one document. For these, direct conversion often creates a reactive notebook that is technically valid but structurally hostile. Extract the reusable logic into modules and rebuild the interactive front end around cleaner boundaries.

A fast triage pass saves time. Search for duplicate names such as df, data, model, and config. Scan for IPython magics such as %pip, %time, or %cd. Look for shell escapes like !ls or !head. Check whether important results exist only as displayed output rather than recomputable values. Those signals tell you whether the notebook is primarily a computation artifact or a session transcript.

What conversion preserves and what it cannot infer

The conversion command preserves source content and translates the notebook into marimo’s Python file format. That alone is valuable. You can diff the result in Git, run standard Python tooling over it, and start moving stable logic into importable modules. The file is no longer a JSON container with embedded cell metadata and stored outputs; it is a Python artifact that can participate in ordinary engineering workflows.

That said, conversion cannot recover semantics that were never explicit. It cannot determine whether a variable existed only because a different cell ran two hours earlier. It cannot decide whether an output was intended as documentation or as a hidden checkpoint. It cannot know that a second definition of df was a harmless exploratory branch rather than a structural dependency violation. It also cannot translate unsupported execution forms into reactive semantics for you.

Two caveats matter immediately. First, because marimo notebooks are Python files, outputs such as plots are not stored in the file itself. A converted notebook must regenerate them. If the old workflow depended on opening a notebook and seeing preserved outputs before any execution, that behavior does not carry over. Second, some marimo library functions, including UI elements, do not work in Jupyter when you export back to .ipynb. Round-tripping exists, but it is not a guarantee that every notebook feature is portable across both environments.

A controlled conversion workflow

A reliable migration flow looks more like software maintenance than file format translation.

Inventory assumptions. Before conversion, identify non-Python constructs, duplicate definitions, hidden imports, broad mutable state, and outputs that serve as storage. Write down the few results that actually matter: a table shape, a model metric, a generated file, a chart property, or a SQL result. Those become your acceptance checks.

Convert one notebook. Use the official conversion command and inspect the resulting .py notebook immediately.

marimo convert analysis.ipynb -o analysis.py

Open and inspect the converted notebook. Run it in marimo and look for graph breaks, stale dependencies, and cells that no longer make sense once execution order stops doing hidden work.

marimo edit analysis.py

Repair unsupported constructs. Replace magics, shell commands, and duplicate definitions with ordinary Python or better cell structure.

Validate from a clean process. Execute the notebook in a fresh run path rather than trusting the editor session alone.

marimo run analysis.py 
python analysis.py

Both commands are useful. The first runs it through marimo’s execution path; the second confirms that the notebook is still an ordinary Python program.

Working through a realistic cleanup

Suppose the original notebook contained these Jupyter-era patterns:

%pip install pandas 
!ls data/ 
%cd /tmp/project 

import pandas as pd 
df = pd.read_csv("data/raw.csv") 
df = df[df["status"] == "active"] 
df = df.groupby("team").sum()

Every line creates migration pressure for a different reason. The magic command is unsupported. The shell escape is unsupported. The directory change needs explicit Python. The repeated reassignment of df compresses multiple states into one name, which is convenient in a REPL and awkward in a dependency graph.

A direct rewrite in ordinary Python is straightforward:

import os 
import subprocess 

subprocess.run(["ls", "data"], check=True) 
os.chdir("/tmp/project") 

import pandas as pd 

raw_df = pd.read_csv("data/raw.csv") 
active_df = raw_df[raw_df["status"] == "active"] 
team_totals = active_df.groupby("team").sum()

This version does more than remove unsupported syntax. It makes the data lineage legible. raw_df, active_df, and team_totals are distinct states, so marimo can track dependencies cleanly and you can inspect intermediate values without relying on whichever meaning df happened to have last.

If you need package changes, keep them out of notebook logic when possible and handle them through the environment workflow discussed later. If you are dealing with a standalone script-style notebook, marimo’s built-in package management and related tooling can help, but that is not a substitute for cleaning the notebook’s semantic structure.

Replacing magics and shell escapes

Unsupported magics and shell escapes are among the first failures you will hit. They are usually easy to replace, but the replacement should reflect intent rather than mimic old syntax mechanically.

%cd maps to os.chdir() when you truly need to change directories, although explicit paths are often safer than process-wide directory mutation.

!command maps to subprocess.run(...) when the shell command is part of the analysis logic. If the command only served as a quick exploratory check, you may be better off deleting it entirely.

%time or related timing magics should become ordinary Python timing tools such as timeit or explicit timers around the code of interest.

%pip and other environment-oriented magics should not remain embedded as analysis steps. Move package installation into the environment definition unless the notebook is intentionally a self-contained standalone artifact.

For introspection commands that depended on the IPython environment, use ordinary Python facilities such as dir() and globals(). marimo also provides notebook-specific references such as mo.refs() and mo.defs() when you need to inspect definitions and references interactively. Use those for debugging structure, not as part of the notebook’s stable analytical logic.

Outputs are no longer storage

A frequent migration surprise is that a notebook looks “emptier” after conversion because the file no longer carries historical plots and tables with it. That is a feature, but it changes your validation workflow.

If a chart matters, the code that generates it must run successfully. If a summary table matters, bind it to a named value and check it explicitly. Do not use visual parity with the old notebook as your only acceptance test. Old outputs may have been stale. The converted notebook forces you to establish what should be recomputed and what should be stored externally.

For expensive intermediate results, choose one of three strategies deliberately: recompute them, cache them through an explicit mechanism outside the notebook file, or persist them as data artifacts. The wrong strategy is leaving them implicit in process memory and hoping conversion will preserve that convenience.

Handling repeated definitions and imperative drift

Duplicate definitions are not just a style issue. marimo’s graph model relies on unique definitions across cells. A notebook that repeatedly reuses the same top-level name often encodes temporal thinking: first df means raw input, then filtered data, then grouped output, then something else entirely. The safe rewrite is to name those states distinctly or move the transformation steps into a function with clear inputs and outputs.

Imperative drift is slightly different. A notebook may technically avoid duplicate definitions yet still depend on mutation:

results = {} 
results["raw"] = load_data() 
results["clean"] = clean(results["raw"]) 
results["model"] = train(results["clean"])

This style hides several dependencies inside one mutable container. In marimo,

that often broadens invalidation and weakens readability. Prefer separate values unless you truly need a compound object with stable semantics.

raw_data = load_data() 
clean_data = clean(raw_data) 
model = train(clean_data)

You gain clearer graph edges, easier inspection, and more controlled recomputation.

py:percent scripts and structured source notebooks

Some teams already use script files with cell markers instead of .ipynb. If your script uses # %% markers, marimo can convert it into notebook cells. The documented path uses jupytext.

uvx --with=jupytext marimo convert \ 
  your_script.py -o your_notebook.py

That workflow is especially useful when a repository has drifted into a mixed style: some work lives in notebooks, some in cell-marked scripts, and some in plain modules. Conversion lets you regularize the interactive artifacts without rewriting everything manually. Still apply the same triage logic. A cell-marked script can hide the same state problems as a Jupyter notebook if its meaning depended on execution order rather than explicit dataflow.

Validation means clean execution plus semantic checks

After repair, validate with a fresh process. Do not trust an already-open editing session because it may still contain transient state from your cleanup work.

marimo run analysis.py

If the notebook is also intended to behave as a script, run it with Python as well.

python analysis.py

Now compare against the baseline you captured during inventory. Check the values that matter, not just whether the notebook rendered without raising an exception. Good validation targets include row counts, aggregate statistics, generated filenames, model metrics, and deterministic text output. If the notebook queries external systems or relies on changing data, validate invariants rather than byte-for-byte equality.

When validation fails, distinguish structural failure from environmental failure. A missing package, wrong interpreter, or absent system binary belongs to environment management. A result mismatch caused by different cell ordering, stale assumptions, or lost hidden state belongs to notebook refactoring.

When to stop repairing and start redesigning

Some notebooks resist direct migration because their shape encodes too much incidental history. Warning signs are easy to spot: one cell performs data access,

cleaning, plotting, and export all at once; many cells exist only to display intermediate artifacts; notebook-wide globals behave like a shared message bus; credentials or machine-specific paths are embedded throughout; the notebook only “works” if you follow a tacit execution ritual.

When those signals are dense, stop trying to preserve notebook shape. Extract stable logic into importable modules, keep the notebook focused on orchestration and inspection, and treat the conversion as a redesign opportunity. The Python file format makes that easier because the converted notebook is already ordinary source code. You can run linters, normalize imports, and move reusable functions without fighting a JSON document format.

Round-trip limits and compatibility boundaries

Export back to Jupyter exists:

marimo export ipynb notebook.py -o notebook.ipynb

Use that for interoperability, review constraints, or handoff requirements when necessary. Do not treat it as a promise of feature equivalence. marimo-specific UI elements and some library functions do not carry into Jupyter intact. If your target artifact must live long-term as a Jupyter notebook, design to the portable subset or accept that the export is a degraded representation.

That distinction affects migration planning. Converting into marimo is usually a move toward a new execution model, not a temporary syntax translation layer. Teams that expect lossless round-tripping often delay the architectural decisions that matter most: removing hidden state, replacing unsupported constructs, and clarifying package and module boundaries.

Common anti-patterns during migration

Convert and trust. A file that opens successfully is not necessarily correct. Always validate from a clean run.

Preserve giant cells to avoid graph issues. Large cells can suppress immediate dependency errors, but they also hide state transitions and expand recomputation scope.

Embed environment repair inside notebook code. Installation commands and machine fixes make notebooks less portable, not more. Handle them through environment management unless the notebook is intentionally standalone.

Accept visual similarity as proof. Old notebooks often preserved stale outputs. Use explicit checks on named values.

Keep duplicate names because “that is how the analysis evolved.” Migration is the point where exploratory evolution must become explicit program structure.

A careful conversion workflow does not aim to preserve every habit of the original notebook. It preserves the analytical intent while discarding the execution accidents that made the original artifact fragile. Once you treat conversion as inventory, translation, repair, and validation rather than as format replacement, the migrated notebook becomes easier to trust, easier to review, and much easier to evolve.

8.3 Establishing Reproducible Environments and Package Boundaries

A notebook can be internally correct and still fail for everyone except its author. You can eliminate hidden execution state, convert the file cleanly, and still end up with a result that breaks on a teammate’s machine because one package version drifted, an optional dependency was never recorded, or the interpreter itself changed under you. Notebook reproducibility and environment reproducibility solve different classes of failure. You need both.

A useful way to make that distinction concrete is to compare two real workflows. The first creates a standalone sandbox notebook with dependencies embedded in the file. The second opens a notebook inside a project-managed environment.

uv add --script notebook.py numpy 
uvx marimo edit --sandbox notebook.py 
uv run notebook.py

uv run marimo edit notebooks/analysis.py

Those commands look similar, but they encode very different promises. The sandbox path says, “this notebook carries its own dependency intent.” The project path says, “this notebook belongs to a larger codebase and must share its environment contract.” Choosing between them is less about personal preference than about artifact lifetime, team size, and how much of the surrounding repository the notebook is expected to understand.

Two layers of reproducibility

Reactive execution gives you semantic reproducibility at the notebook level. If a cell changes, dependents rerun or become stale according to graph semantics. That protects you against hidden kernel history and out-of-order execution accidents.

Environment reproducibility operates outside that graph. It answers different questions: Which Python version is valid? Which top-level packages are required? Which transitive versions were resolved? Are there platform-specific binaries or optional extras? Is the notebook supposed to work in isolation or only inside a repository with package metadata and a lockfile?

Keep those layers separate in your mental model. A notebook can be semantically deterministic and still fail because pandas or numpy is missing. The reverse also happens: you can have a perfectly locked environment and still produce wrong results if the notebook relies on hidden execution order. One layer cannot compensate for defects in the other.

Choose the environment model before adding dependencies

marimo supports two main environment patterns: sandbox notebooks with inline metadata, and project notebooks sharing the project environment. Make that choice early because it affects how dependencies are recorded and how teammates will run the notebook.

Use a sandbox notebook when the notebook is a standalone example, demo, tutorial artifact, or a self-contained analytical deliverable that should travel well on its own. In sandbox mode, marimo tracks package names and versions in the notebook file and runs the notebook in an isolated environment containing only notebook dependencies.

Use a project notebook when the notebook lives inside a larger repository, imports project modules, participates in the same CI assumptions as the rest of the codebase, or should evolve with shared package governance.

This is a package-boundary decision as much as an execution decision. If the notebook’s imports and behavior are meaningful only inside a project, putting its dependencies inline often creates drift between the notebook and the repository it depends on. If the notebook is meant to stand alone, forcing it to depend on a repository-level pyproject.toml usually makes sharing harder than necessary.

Sandbox notebooks and inline metadata

Sandbox mode is built around inline script metadata. The metadata follows PEP 723, which lets a single Python file describe its dependency needs and supported Python version. A minimal header looks like this:

# /// script 
# requires-python = ">=3.11" 
# dependencies = [ 
# "numpy", 
# "pandas", 
# ] 
# ///

You do not have to write this block manually if you use the supported tooling. Add or remove dependencies directly against the script:

uv add --script notebook.py numpy 
uv add --script notebook.py pandas 
uv remove --script notebook.py pandas

Then open or run the notebook in sandbox mode:

marimo edit --sandbox notebook.py 
marimo run --sandbox notebook.py

You can also launch the editor without preinstalling marimo into a long-lived environment:

uvx marimo edit --sandbox my_notebook.py

This pattern is excellent for examples, demonstrations, and notebooks you want to email, publish, or drop into a documentation repository without requiring a surrounding project structure. The dependencies are visible in the same file as the analysis. The runtime environment is isolated to the notebook’s declared needs. That sharply lowers the ambiguity that often surrounds “small” notebooks shared outside their original repository.

Project notebooks and shared environments

A project notebook should usually share the same environment contract as the rest of the repository. That means package metadata belongs in pyproject.toml, not scattered across individual notebooks. The notebook becomes one consumer of the project environment rather than its own mini-package manager.

A minimal project layout often starts with a file like this:

[project] 
name = "analysis-project" 
version = "0.1.0" 
dependencies = [ 
 "numpy", 
 "pandas", 
 "scikit-learn", 
] 

[dependency-groups] 
dev = ["marimo", "pytest"]

Run the notebook through the project environment:

uv run marimo edit notebooks/analysis.py

This choice pays off when notebooks import local modules, share utilities with scripts or services, or must remain consistent with tests and deployment artifacts. It also enforces a healthier ownership boundary. Runtime dependencies stay in the project definition; notebook and testing tools can live in a development group; the notebook itself focuses on analysis logic instead of environment assembly.

The trade-off is intentional coupling. A project notebook is less portable as a single file because its environment contract lives at repository scope. That is usually the right trade for long-lived work, where consistency matters more than single-file convenience.

A failure mode that looks convenient until it is not

One workflow deserves explicit caution because it feels practical in the moment:

uv run --with marimo marimo edit my_notebook.py

This can open the editor even when marimo is not a project dependency. The problem appears later. If you install packages via the marimo UI in that session, those packages are not added to the project definition and may disappear the next

time someone recreates the environment. You get an apparently working notebook whose dependency history was never recorded where the project expects it.

That failure is subtle because the notebook may behave correctly on the original machine for days. The break shows up when a teammate runs uv run, when CI recreates the environment, or when you clean and resync the project. If the notebook belongs to the project, add marimo to the project’s development dependencies and use uv run marimo edit.... Do not treat ephemeral tooling injection as a substitute for project metadata.

Dependency capture versus dependency governance

Inline notebook dependencies and project dependencies solve related but different problems.

Dependency capture answers, “What does this notebook need to execute?” Sandbox metadata is good at this. It keeps the answer close to the artifact.

Dependency governance answers, “What does this repository support, test, and maintain over time?” Project metadata and lockfiles are good at this. They create a team-visible contract.

Confusing the two creates maintenance debt. If every notebook in a repository captures its own slightly different dependency set, you may lose coherence across analyses that are supposed to share semantics. If every throwaway example requires a full project bootstrap, you raise the cost of sharing and experimentation unnecessarily.

Use the notebook file to capture dependencies when the notebook is the product. Use the project definition when the notebook is one component of a broader software system.

Lockfiles and what they actually buy you

Package lists are not enough for reliable reproduction over time. A pyproject.toml entry like "pandas" gives you intent, not a complete installation state. Resolution can still change as transitive packages evolve. That is why lockfiles matter.

In a uv-managed project, uv.lock is the primary lockfile for reproducibility within the uv toolchain. Commit it alongside pyproject.toml and your notebooks. Do not commit the managed virtual environment directory.

A minimal hygiene rule is easy to state:

uv sync 
uv lock

Then commit files such as:

pyproject.toml
uv.lock
notebooks/analysis.py
src/...
tests/...

Do not commit:

.venv/

uv creates a persistent .venv next to pyproject.toml for managed projects, and that directory is not meant for version control. Commit the declarative and lock artifacts; let each machine materialize the environment locally.

For cross-tool interoperability, uv can export a standardized lock representation, but within a uv-centered workflow, uv.lock remains the authoritative file. Use exports when you need them, not as a replacement for the native project lock.

Interpreter version is part of the contract

Environment drift is not limited to packages. Python version is part of execution semantics, packaging resolution, and sometimes extension compatibility. Sandbox notebooks can record a Python requirement inline using requires-python. Project notebooks should express interpreter expectations in project metadata and team tooling. If your notebook only works on a recent Python and you fail to record that fact, every other dependency declaration is weaker than it looks.

This matters especially for data and AI work because native extensions, array libraries, and compiled dependencies often have narrower interpreter support windows than pure-Python packages. Record the interpreter boundary early; otherwise a teammate can spend hours debugging what looks like a package problem but is actually a Python-version mismatch.

Package boundaries are design boundaries

A notebook import list tells you more than what to install. It tells you where responsibility is accumulating. If a notebook imports database clients, plotting libraries, cloud SDKs, model toolkits, and several internal helper modules, it may already be crossing too many boundaries for a stable single-file artifact.

Use imports to audit notebook scope. If the notebook depends on reusable business logic, move that logic into a package or module and keep the notebook as an orchestration and inspection surface. If a notebook carries one-off exploratory libraries that do not belong in the runtime surface of the repository, keep them isolated in a sandbox notebook or a dedicated optional environment path rather than promoting them silently into core project dependencies.

This is where packaging discipline helps rather than hinders exploration. A project with explicit boundaries can support notebooks more safely because you know what is runtime code, what is developer tooling, and what is experimental overhead.

Decision signals for choosing sandbox or project mode

Use sandbox mode when most of the following are true:

The notebook should run as a standalone artifact.

It has few contributors.

It does not import substantial local project code.

Sharing convenience matters more than repository-wide consistency.

The dependency surface is modest and mostly notebook-specific.

Use project mode when most of the following are true:

The notebook imports local modules under active development.

Multiple contributors need one shared environment contract.

CI, testing, or deployment workflows already exist.

The artifact must remain maintainable for months rather than days.

Native libraries, optional extras, or platform-specific setup make environment drift expensive.

There is no benefit in forcing everything into one model. The mistake is inconsistency within the same responsibility boundary. A repository can contain both standalone sandbox examples and project-bound notebooks, but each notebook should make its ownership model obvious.

Common failure modes

Undocumented system dependencies. Python metadata does not capture every external requirement. Database drivers, command-line tools, GPU runtimes, and OS libraries still need separate documentation or provisioning.

Permissive dependency ranges with long-lived notebooks. Loose version constraints may be acceptable during rapid research, but they accumulate risk in notebooks expected to survive team handoff or CI execution.

Over-pinning too early. Pinning every experimental dependency aggressively can slow iteration and create unnecessary upgrade friction. Match strictness to the notebook’s intended lifespan.

Secrets and machine-specific paths in notebook code. These are not environment specifications; they are local accidents. Keep them out of notebooks and inject them through proper configuration channels.

Notebook-local installs in project notebooks. This splits the source of truth. If the project owns the environment, record dependencies in the project definition.

Operational discipline that scales

A reproducible workflow is mostly a matter of putting the right facts in the right place. Put notebook-specific dependencies in inline metadata when the notebook is meant to stand alone. Put shared dependencies in pyproject.toml when the notebook belongs to a repository. Commit lockfiles, not virtual environments. Record Python-version constraints, not just package names. Treat imports as a signal that reusable logic may need to leave the notebook.

Once those boundaries are clear, the notebook stops carrying environmental ambiguity on its own. It becomes either a self-describing standalone program or a well-behaved member of a larger Python project, and both are far easier to run again months later than a notebook that merely happened to work on one machine.

8.4 Designing Repository Layouts for Long-Lived Data and AI Work

A repository starts to decay long before it looks disorganized. The first warning sign is usually not a failing test or a broken deployment. It is a notebook that can no longer explain where its logic lives, which environment owns its dependencies, or whether a helper function is the canonical implementation or just a copy from an older experiment. Once a team has more than a handful of notebooks, layout becomes an operational control surface. A good layout reduces accidental coupling, makes reusable code easier to test, and keeps notebooks valuable as interactive

entry points rather than turning them into the place where every concern accumulates.

You can see the difference immediately by comparing two concrete patterns. A library-oriented repository keeps reusable code under a package and treats notebooks as examples. An analysis-oriented repository keeps notebooks inside the main project environment and lets them orchestrate domain-specific work over imported modules.

uv init --lib 
uv add --group dev marimo 
uv run marimo edit examples/quickstart.py

That setup points toward a package-first repository. The notebook is present, but it is not the ownership boundary for core logic. For a durable example notebook, you can make the local package available to a sandbox notebook as an editable dependency:

uv add --script examples/quickstart.py --editable

Those commands encode a design decision: notebooks remain executable and shareable, while the package owns the stable implementation. That is the key structural move that keeps exploration from collapsing into repository sprawl.

The repository is a boundary map

In long-lived work, the repository layout should answer four questions without requiring tribal knowledge.

Where does reusable logic live? That belongs in importable modules or packages, not in notebook cells copied across files.

Where do interactive entry points live? That belongs in notebooks or scripts whose job is orchestration, inspection, and presentation.

Where is the environment contract defined? That belongs either in notebook-local metadata for standalone artifacts or in pyproject.toml and its lockfile for project-owned work.

Where do tests attach? They should attach first to reusable modules, because module logic is easier to run, isolate, and maintain than notebook UI or rendering flows.

If your repository cannot answer those questions cleanly, the layout is already working against you.

Pattern 1: library-first repositories with runnable examples

This pattern works well when your team is building reusable data-processing code, model utilities, domain-specific transformations, or an internal library that needs interactive examples. The core code lives in a formal package, usually under a src/ layout, while notebooks act as demonstrations, tutorials, or exploratory front ends.

A minimal shape looks like this:

my_library/
├── pyproject.toml
├── uv.lock
├── src/
│   └── my_library/
│       ├── __init__.py
│       ├── features.py
│       └── metrics.py
├── examples/
│   ├── quickstart.py
│   └── advanced.py
└── tests/
    ├── test_features.py
    └── test_metrics.py

This arrangement has several advantages.

The package under src/ becomes the single source of truth for reusable logic. You can test it directly, import it from notebooks and scripts, and review it like any other Python code. The examples/ directory becomes intentionally user-facing: each notebook is a runnable artifact that teaches or demonstrates how to use the package.

Sandbox notebooks are especially strong in this pattern because examples often need to be portable. A quickstart notebook that carries its own Python version and dependency metadata can be shared externally without forcing readers to bootstrap the entire development repository. At the same time, making the local package an editable dependency keeps the example aligned with the current source tree.

That last detail matters. Without the editable dependency, the example may silently drift toward whatever published version of the package happened to be installed. With it, the example exercises the local code under development, which is exactly what you want for documentation, demos, and API validation.

What belongs in the notebook in a library-first design

In a healthy library-first repository, the notebook should do a small number of high-value things:

assemble inputs for a concrete scenario;

call package functions or classes;

visualize, inspect, or compare outputs;

expose parameters that make the example educational;

demonstrate end-to-end usage without reimplementing internals.

What should not live there is the stable implementation of feature engineering, model evaluation, parsing, or data access abstractions that other artifacts will need. If several notebooks define the same helper, that helper already belongs in the package.

This boundary preserves the notebook’s strengths. It remains interactive, readable, and close to the user experience of the library. It does not become a second, untested codebase hiding beside the real one.

Pattern 2: analysis-centric repositories with shared environments

Some repositories are not libraries with examples. They are analytical projects in which notebooks are first-class working artifacts: model evaluation, exploratory reports, dataset audits, forecasting experiments, or internal decision support. In that case, keeping notebooks in the shared project environment is often the better pattern.

A typical layout looks like this:

analysis_project/
├── pyproject.toml
├── uv.lock
├── notebooks/
│   ├── cohort_analysis.py
│   ├── error_review.py
│   └── model_benchmark.py
├── src/
│   └── analysis_project/
│       ├── io.py
│       ├── transforms.py
│       └── scoring.py
├── tests/
│   ├── test_transforms.py
│   └── test_scoring.py
└── docs/
    └── data_sources.md

Here the notebooks are not peripheral examples. They are operational analysis surfaces. That changes the right environment strategy. Rather than giving every notebook its own sandbox metadata, manage dependencies centrally in pyproject.toml. Run notebooks through the project environment so imports, tooling, and tests all share one contract.

This pattern works especially well when notebooks import local modules under active development, when multiple contributors collaborate on the same analytical code, or when CI and review standards matter. It reduces environment fragmentation and forces the team to decide which dependencies are part of the analytical platform rather than silently letting every notebook evolve its own package set.

What belongs in modules in an analysis-centric design

Even when notebooks are central, not everything should remain in them. Move logic into modules when any of the following becomes true:

more than one notebook needs the same transformation;

correctness matters enough to warrant direct tests;

the code has stable inputs and outputs independent of UI;

the logic is easier to reason about outside notebook cell boundaries;

you may later reuse it in scripts, services, or scheduled jobs.

Good candidates include data loading adapters, schema validation, feature transformations, scoring logic, and metric computation. Poor candidates are notebook-specific chart layout, interactive control wiring, and narrative inspection code that only makes sense in one artifact.

The rule is not “move everything into modules.” The rule is “move stable, reusable, testable logic into modules.” The notebook should still own orchestration and interpretation.

Pattern 3: AI experimentation repositories

AI work adds its own pressure because experimentation often produces many notebooks quickly, each with overlapping utilities, model wrappers, prompt variants, or evaluation code. If you do not establish structure early, the repository fills with near-duplicate notebooks and a vague utils.py that owns everything and nothing.

A durable AI experimentation repository usually benefits from a hybrid of the previous two patterns. Keep notebooks for experiment orchestration and analysis, but formalize model interfaces, dataset preparation, evaluation routines, and prompt helpers under importable modules. Group notebook artifacts by experiment family or domain rather than by individual author. Record environment ownership explicitly, because model and inference stacks often have sharper version and platform constraints than ordinary analytics code.

A common smell in AI projects is parameter duplication. The same model identifier, dataset path, scoring threshold, or prompt template appears in three notebooks and one script. Once that happens, the repository no longer has a clear control point. Extract the shared configuration or helper logic into a module, and let notebooks import it. You gain consistency and make later automation much easier.

When a monorepo needs workspaces rather than path hacks

As repositories grow, teams often start with ad hoc import tricks: mutate sys.path, rely on relative directory layout, or assume a particular working directory before opening a notebook. Those shortcuts do not survive scale.

When you have multiple packages in one repository, prefer uv workspaces over loose cross-directory imports. That gives each package a clear boundary while still letting the repository coordinate them as one system. It is a much stronger

foundation for notebooks that need to import several internal packages without depending on fragile path surgery.

Ad hoc path manipulation causes three distinct problems. It hides dependency structure from tooling, makes execution dependent on launch location, and creates review noise because import behavior is encoded procedurally instead of structurally. A workspace or formal package configuration solves all three more cleanly.

Trade-offs: sandbox notebooks versus shared project notebooks

The earlier environment discussion becomes concrete at repository scale.

Sandbox notebooks maximize portability and isolation. They are excellent for durable examples, external tutorials, and artifacts meant to survive outside the parent repository. The cost is fragmentation. If ten notebooks each carry their own dependency metadata, keeping them aligned becomes work.

Project notebooks centralize dependency management and reduce drift across the repository. They fit team collaboration, CI, and shared module development. The cost is tighter coupling. A notebook is no longer a self-contained object; it depends on the surrounding project environment.

Choose based on ownership and maintenance horizon. If a notebook is effectively documentation for a library or a standalone deliverable, sandboxing is a strength. If a notebook is one view into an actively maintained codebase, centralizing the environment is usually the safer choice.

A practical growth path

You do not need a perfect architecture on day one. A repository often matures in stages.

Start with a notebook-centric prototype when the problem is still being discovered. Keep the notebook clear and reactive; avoid hiding too much logic in mutable session state.

As stable patterns emerge, extract reusable code into modules under src/. Add tests around those modules first. Module tests usually provide the highest return because they validate logic independently of notebook rendering and UI behavior.

Once multiple artifacts depend on the same environment contract, standardize pyproject.toml, commit uv.lock, and stop allowing environment fixes to live inside notebook code. At that point, notebooks become consumers of the repository rather than owners of its entire behavior.

This progression preserves speed early without sacrificing maintainability later.

Version control hygiene and review surfaces

A clean repository layout depends on committing the right artifacts and ignoring the wrong ones. Check in source notebooks, pyproject.toml, and uv.lock. Do not commit .venv. uv-managed projects expect the environment to be recreated locally from declarative files, not shared as a directory blob.

The Python-native notebook format makes review significantly easier. Because notebooks are plain Python files, diffs focus on source changes rather than opaque JSON structures. That benefit compounds when repository boundaries are clean. Reviewers can tell whether a change belongs in a notebook, a module, a test, or the project metadata. If every change lands in a notebook because the repository has no other obvious home, review quality drops quickly.

Anti-patterns that age badly

All business logic in notebooks. This feels fast until two notebooks need the same behavior and drift apart.

A single shared utils.py. Without clear ownership boundaries, it becomes a dumping ground for unrelated helpers and hidden dependencies.

Environment bootstrapping in notebook cells. That makes execution order part of environment correctness and breaks team reproducibility.

Duplicated parameters across notebooks and scripts. Configuration sprawl is a layout problem disguised as convenience.

App-facing notebooks that depend on exploratory side effects. A notebook used as a deliverable surface cannot rely on hidden assumptions left over from research work.

Relative path surgery for imports. If the notebook only works when launched from one directory, the repository has not encoded its structure properly.

Conventions that reduce chaos

A few conventions carry disproportionate value in long-lived repositories:

Keep notebooks near the code or domain they exercise.

Prefer package imports over directory manipulation.

Treat src/ modules as the home for stable logic.

Keep example notebooks separate from project-owned analyses when their environment needs differ.

Commit pyproject.toml, uv.lock, and source notebooks; ignore .venv.

Name notebooks by analytical purpose, not by person or date, unless chronology itself is the organizing principle.

Those choices make the repository easier to navigate and easier to evolve. More importantly, they keep notebooks valuable for what they do best: interactive reasoning, inspection, and communication over code that lives in a structure the rest of the team can maintain.