Hash all the things: Caching for fast notebook restarts
Abstract¶
We describe a caching mechanism that lets reactive notebooks restart without re-running their expensive cells. The mechanism is built into marimo, a reactive Python notebook that models the notebook as a dataflow graph. Each cell’s cached result is identified by a key built from fingerprints (hashes) of the cell’s code and inputs: an input whose bytes are accessible is hashed directly, and any other input is represented by the key of the cell that produced it, computed the same way. Because each cell’s key folds in the keys of the cells it depends on, editing one cell invalidates the cached results of exactly the cells downstream of it. Cached values are stored on disk and loaded only when accessed, so they can be reused across independent runs of the same notebook. They are also bundled into marimo’s static export, a standalone web page (HTML) that runs the notebook through WebAssembly (WASM), so readers whose only Python runtime is a browser can open a notebook with its expensive results and trained models already computed. In microbenchmarks over payloads of varying size, a marimo cache hit is comparable to widely used Python caching libraries, with a speedup on certain hardware, while asking almost nothing of the user.
Introduction¶
Notebooks underpin much of scientific Python, but most notebooks cannot be re-executed from scratch.
In a survey of 1.4 million public Jupyter notebooks, only about a quarter re-executed top to bottom without raising an error Pimentel et al., 2019.
Traditional notebooks like Jupyter, built on the imperative ipykernel, are read-evaluate-print loops (REPLs) in which each cell execution mutates shared global state.
As a result, the notebook accumulates hidden state, and the outputs saved in the notebook file can differ from the outputs that a top-to-bottom run would produce.
Reactive notebooks close this gap by treating each cell as a node in a dataflow graph.
The notebook determines, for each cell, which variables it reads (its references, or refs) and which variables it defines (its definitions, or defs).
From these, a reactive notebook derives a deterministic execution order based on data dependencies rather than on the order of cells on the page.
Running a cell removes the cell’s previous variable bindings from memory, updates the dataflow graph if the cell’s code changed, and re-runs the cells that depend on it, which minimizes hidden state.
Notable reactive notebooks include Pluto.jl Plas & Pluto.jl contributors, 2020, Observable
Bostock, 2017, and Livebook Valim & Livebook Team, 2020. Reactive notebooks
descend from a longer tradition of direct-manipulation programming environments
Victor, 2012, in which editing the source is itself the act that
updates the running program. marimo Agrawal & Scolnick, 2023 is a reactive notebook for
Python, and its caching mechanism is the subject of this paper.
To mitigate unnecessary re-runs, reactive notebooks offer runtime configuration, such as lazy executors that mark cells stale instead of running them; however, these primitives require user intervention. We exploit their deterministic execution order to build a caching mechanism that automatically eliminates unnecessary recomputation of expensive cells under the right conditions.
Caching for notebooks and Python is not new; we review prior systems in Background and Related Work. Each asks the user to opt in at a boundary, such as a decorated function, document chunk, or session. Reactive notebooks already draw boundaries around every cell, reducing cognitive overhead.
The caching mechanism we propose, and whose implementation we share, was designed to satisfy three properties.
Skip expensive recomputation when a cell’s references and source are unchanged.
Preserve reactive determinism by reusing a result only while it stays valid and never serving a stale (false-positive) hit.
Make cached artifacts transportable through marimo’s static WASM/HTML export.
Out of scope are full session restoration in the sense of Kishu Li et al., 2025, distributed execution, and reproducibility of arbitrary Python notebooks Pimentel et al., 2019. Our contribution is deterministic reuse between potentially cross-platform notebook sessions that follow marimo’s reactive principles.
Background and Related Work¶
Hashes. A hash is a short, fixed-size fingerprint of data; any change yields a different fingerprint. With a cryptographic hash function, finding two different inputs with the same fingerprint is computationally infeasible, so matching fingerprints can be treated as matching data. A value is content-addressed when it is identified by a hash of its bytes: two values with the same bytes receive the same identity.
Memo functions. Michie’s memo functions Michie, 1968 describe function caching: a cached function skips recomputation when a key derived from its inputs matches a stored key and returns the stored value. To cache a cell the same way, the key must reconstruct identically under identical conditions. That requirement forces the central question of this paper: what are a cell’s inputs?
A cell’s inputs. A reactive notebook gives two answers. Statically, it derives a dependency graph from source that is stable across runs. At runtime, it knows the values currently bound in memory. The cache key must therefore be a function of the graph the source defines and of the values present when the cell runs, not of any particular execution trace. Cell-level dataflow tracking has an earlier antecedent in Koop & Patel, 2017.
Runtime-tracing for reactive notebooks. IPyflow and nbsafety Macke et al., 2021Macke, 2022 take a different route to reactivity: they retrofit it onto Jupyter by tracing execution to record which variables each cell reads and writes, and then flagging or re-running cells whose inputs have become stale. Runtime tracing has drawbacks: the traced graph reflects only observed executions, so it can differ between sessions of the same notebook. A graph derived from the source code, like marimo’s, avoids both problems.
Build systems. Our key construction borrows from build systems.
Build Systems à la Carte Mokhov et al., 2018 decomposes a build system into a rebuilder, which decides when to re-run a task, and a scheduler, which decides the order.
In those terms, marimo’s cache compares keys hashed from a cell’s code and inputs (Cache Keys) as a rebuilder, paired with marimo’s reactive scheduler, and keeps no persistent record of past builds.
The recursive key construction comes from the Nix package manager Dolstra et al., 2004Dolstra, 2006.
Nix identifies each package by a hash of its inputs, including hashes of the packages it was built from, so its identity recursively covers its dependency tree.
We apply the same construction to a reactive notebook instead of a static package graph.
Data-engineering systems apply the same discipline at coarser granularity: Bauplan and Nessie hash pipeline stages Greco & Tagliabue, 2024, and the workflow engines Nextflow (-resume) and Snakemake (--cache) key task results on code, parameters, and input hashes Di Tommaso et al., 2017Mölder et al., 2021.
Caching for Python and notebooks. Caching tools for Python and notebooks draw opt-in boundaries at different places.
IncPy modifies CPython, the standard Python interpreter, to memoize function calls automatically Guo & Engler, 2011.
knitr caches chunks of literate documents, with dependencies declared by hand Xie, 2015.
jupyter-cache re-executes a notebook wholesale when any code cell changes Executable Books Project, 2020.
Streamlit asks the user to choose between two caching decorators, cache_data for values that can be identified by their content and cache_resource for values that can only be identified by what produced them Streamlit Team, 2023; marimo’s key construction makes this choice automatically (Constructing the key).
Memoizers for scientific Python. mandala Makelov, 2024 is the closest analog to our mechanism.
It memoizes calls inside a with storage: context, computes content addresses with joblib.hash, and records which calls produced which values.
diskcache Jenks, 2016 stores values under raw byte keys that the caller constructs, so it measures storage cost alone and serves as our control.
joblib’s Memory Joblib Developers, 2024, the standard persistent memoizer in scientific Python, keys on the pickled arguments of each decorated function.
Kishu Li et al., 2025 and ElasticNotebook Li et al., 2024 checkpoint and migrate notebook state; they complement a cache rather than compete with one.
Only mandala and diskcache are directly comparable to marimo’s mechanism, and we benchmark against both in Evaluation.
Cache Keys¶
In computational caching, a false positive — restoring a value that the code would not have produced — is unacceptable. A false negative — failing to find a stored value and recomputing it — merely wastes time and is acceptable because the user can understand the caching criteria. The key derivation must therefore change whenever a value the cell reads changes, and it should not change under superficial edits such as reformatting or comment changes.
Consider two obvious ways to derive a key. The first is to hash the value of every reference the cell reads. A marimo notebook could attempt this, since at runtime it exposes both the dataflow graph and the reference values bound in memory. This derivation fails because some Python values expose nothing stable to hash. An object’s memory address is neither stable nor meaningful as an identity; weak references report which object they point to, not what it contains; opaque C-extension objects expose neither their underlying bytes nor a stable text representation. The second derivation is to hash the cell’s source bytes alone. This fails in the opposite way: the key is computable but does not change with the cell’s inputs, producing the false positives ruled out above. It misses inputs that arrive through side effects such as the filesystem, the network, or the wall clock.
marimo combines the two derivations: a value that can be hashed is hashed, and a value that cannot is identified by the code that produced it. Each derivation covers the other’s failure: the first keeps the key sensitive to inputs and the second keeps it computable. Build systems handle unhashable artifacts the same way: an artifact that exposes no content to hash is identified by the build step that produced it Dolstra et al., 2004. marimo applies this idea recursively over the dataflow graph. Constructing the key gives the precise construction. Invalidation then examines invalidation: editing a cell invalidates only downstream cached results, and recomputing keys takes time proportional to affected cells. Side effects, which neither derivation sees, remain only partially covered; we return to this limitation in Limitations and Discussion.
Constructing the key¶
Here we describe a cell’s cache key, computed from its source code and references. Throughout, denotes the key of cell .
Hashing source code¶
A cell’s references identify what flows into it, and its code determines what it computes from those inputs.
The code must therefore enter the key.
Without it, two cells that read the same input, such as y = x + 1 and y = x - 1, would share a key, and one could be served the other’s cached result.
Hashing code provides basic invalidation: editing a cell changes its key, so its stale result is never reused.
marimo hashes the compiled bytecode rather than the source text. Compilation discards edits that cannot change behavior, such as comments and formatting, so they do not invalidate the cache. The trade-off is that bytecode is specific to the Python version. Cached results therefore do not transfer across interpreter upgrades, and marimo’s browser export (WASM portability) requires the exporting interpreter to match the browser’s Python version.
Hashing references¶

Figure 1:Hashing references.
Left: each reference is tried against three strategies in order, and the first that applies yields that reference’s hash.
An immutable value is hashed directly (labeled Pure).
A value that exposes its bytes through the buffer protocol has those bytes hashed (ContentAddressed).
Any other value contributes the key of the cell that produced it (ExecutionPath).
The reference hashes and the hash of the cell’s compiled body are then combined into a single hash: the cell’s key, .
Right: the same derivation applied to a whole cell.
The listing reuses these labels to classify the whole cell’s key by the strongest fallback it needed, and adds ContextExecutionPath, a special case described in the text.
Each reference is tried against three strategies. The first that applies yields its hash; we call this decision the key dispatch. The strategies — direct hashing of immutable values, content addressing, and producer substitution — are described below and illustrated in Figure 1.
Immutable values. An immutable value, such as a number, a string, or a frozen collection, is hashed directly.
The figure labels this case Pure.
Interactive inputs reach this case through normalization: before hashing, a reference to a user interface (UI) element such as a slider is replaced by its current value (line 3), so the key changes when the reader moves the slider.
Content-addressed values. A value that exposes its underlying bytes through Python’s buffer protocol — the standard mechanism for exposing an object’s raw bytes without copying — has those bytes hashed.
The figure labels this case ContentAddressed.
This case covers NumPy ndarrays and other objects advertising NumPy’s array interface.
The hash is computed from the contiguous buffer without serialization, an idiom borrowed from joblib Joblib Developers, 2024 and mandala Makelov, 2024.
Producer substitution. Any other value exposes nothing stable to hash, but a known upstream cell produced it.
Instead of hashing the value, marimo uses the producing cell’s key as the reference’s hash.
The producing cell’s key uses this construction, covering its code, inputs, and everything upstream.
A change anywhere in the value’s ancestry therefore changes the reference’s hash.
The figure labels this case ExecutionPath.
Combining hashes¶
All hashing in this construction uses SHA-256, as Figure 1 shows. The hashes of the code and of the references are combined into one in the combine step at the bottom of the figure. Every reference hash, in sorted reference-name order, is fed with the bytecode hash into one SHA-256 computation, so processing order cannot affect the result. The resulting digest is the cell’s key, . Because SHA-256 is cryptographic (Background and Related Work), keys match only when the code and every reference contribution match, so key equality safely stands in for “this cell would compute the same values.”
Caching blocks and functions¶
The cached unit is not always a whole cell: it can be a block of code inside a cell, or a function (Using marimo’s cache).
A reference defined earlier in the same cell as a cached block has no parent cell whose key could stand in for it.
Instead, the code surrounding the block is folded into the key, a special case of producer substitution that the implementation calls ContextExecutionPath.
For a cached function, the same dispatch classifies the function’s arguments at call time.
Invalidation¶
The construction of Constructing the key is recursive: a cell’s key can contain its producer’s key in turn. Yet no key is derived by walking the whole ancestry. When a cell finishes, marimo records ; when a downstream key needs it for producer substitution, marimo reuses the recorded value. Each cell’s key is therefore computed at most once per execution.
A cached result is invalidated when its cell’s key changes: the new key matches no stored entry, so the next lookup misses and the cell recomputes. Invalidation is not an action marimo performs, and nothing is deleted. The old entry simply stops being found.
Cache Keys opened by ruling out false positives: the cache must never serve a value the code would not have produced. For invalidation, that means never missing a change: whenever re-running a cell could produce a different value, the cell’s key must have changed. To keep false negatives rare, invalidation should be limited: an edit should not invalidate results it cannot affect, and updating keys should be cheap. The two subsections below establish each property in turn.
Invalidation never misses a change¶
For a reference hashed by content, the property is immediate: changing bytes changes its hash and every key built from it. The case that needs an argument is producer substitution, which judges a value by its origin rather than its content. Could the producing cell’s key stay the same while the value it produced changes? In a reactive notebook, no. A value changes only if its producing cell re-runs, and a cell re-runs only when its code or inputs change — its key’s ingredients. An unchanged producer key therefore implies an unchanged value if cell bodies are deterministic; side effects are the exception (Limitations and Discussion). This establishes reactive determinism (Introduction): a cached value is what re-running the cell would produce. This argument is what requires a reactive notebook.
Caching never requires the value itself to be hashable, only that the producing cell have a key. The substitution is a special case of Hughes’s lazy memo functions Hughes, 1985: two values are treated as equal because they come from the same execution of the same code, not because their contents were compared.
Invalidation is limited and cheap¶
With producer substitution, a cell’s key contains upstream cell keys. The notebook’s keys therefore form a Merkle directed acyclic graph (DAG) Merkle, 1988, a structure in which each node’s fingerprint depends on the fingerprints of the nodes it builds on. Git commits use the same construction. Two properties follow. First, editing a cell changes keys only downstream, so every other cached result remains valid. Second, where a re-run cell produces byte-identical values, content-addressed consumers keep their old keys and propagation stops. Recomputing keys after an edit takes time proportional to changed keys because every other recorded key is reused.
A worked example¶
Figure 2 traces the construction on the four-cell PyTorch graph below: a seed, a random tensor generated from it, an independently constructed small neural network, and a forward pass that applies the network to the tensor.
The cells exercise all three strategies: the seed is hashed as an immutable value, the tensor through its buffer, and the network (TinyNet), which exposes no bytes, by its producing cell’s key.
The italic label under each cell in the figure classifies that cell’s own key, as computed by marimo’s hasher: a is Pure (no references), b and c are ContentAddressed (every reference hashed by value), and d is ExecutionPath (one reference substituted a producer’s key).

Figure 2:A worked example of the recurrence on the four-cell graph written out above.
Every hash and branch label in the figure is produced by marimo’s real hasher on a compiled cell graph at render time.
Changing the seed () invalidates a, b, and d (red edges); c stays cached because the seed is not among its references.
Re-rendering with the same seed () leaves every hash unchanged (green edges), so the rebuilder reuses every result.
The italic label under each box names the dispatch branch that cell exercises.
Storage and Loading¶
On a cache hit, marimo restores the variables the cached cell would have defined. Restoring has two parts: lookup finds the stored entry matching , and loading deserializes its values into memory. They need not happen together because the notebook does not always need a value’s bytes. For example, a downstream cell may pass a variable to a third cell without inspecting it. marimo therefore lets loading lag lookup, and offers two loaders that differ in how far.
The default loader, PickleLoader, does not lag at all.
It writes the full Cache envelope, the record holding every variable the cell defined, as a single blob using pickle, Python’s built-in serializer.
On lookup, it loads every variable back immediately.
A second loader, LazyLoader, writes a JSON (JavaScript Object Notation) manifest listing each variable, alongside one blob file per value.
For each variable, the manifest records either the value itself (small primitives are inline) or its blob-file name.
Its cache_type field records which strategy of Constructing the key produced the key.
Abridged, a manifest for a cell that defined a seed and a large array might read:
{
"hash": "9V5v6Cji…",
"cache_type": "ContentAddressed",
"defs": {
"seed": {"primitive": 7},
"x": {"reference": "blob-3fa9…"}
},
"stateful_refs": [],
"meta": {"version": 4, "blob_hashes": {"blob-3fa9…": "e3b0c4…"}}
}On lookup, LazyLoader binds each variable not to its value but to a stub: a small placeholder object that records where the value’s bytes live and how to deserialize them.
All stubs share a load() method, with one type per storage format: pickle, joblib, NumPy .npy, and Apache Arrow.
The stub loads the value on first use; unused variables are never loaded.
WASM portability¶
Cached values also work in marimo’s static WASM export: a standalone HTML file that runs the notebook in the reader’s browser without a server on Pyodide, a Python runtime compiled to WebAssembly.
Exporting a notebook through marimo’s command-line interface (marimo export html-wasm --execute) bundles the cache manifests and blobs into that file.
When a reader opens it, the browser session derives the exporting machine’s keys, so every lookup hits and each value loads on first use as in an interactive session.
Scientific articles, blog posts, and educational materials can therefore include precomputed results and trained models.
The export contains only the cached values and the user code that produced them, not external libraries.
A cell may therefore depend on packages the browser cannot import, as long as the values it defines are stored in a portable format.
Our demonstration, published at https://onnxruntime-web, an ONNX runtime for the browser, restoring a model the notebook can call.
Using marimo’s cache¶
marimo provides three cache mechanisms: in-memory caching within a session, persistent caching across sessions, and a mode that caches every cell automatically.
None of the three asks the user to declare dependencies or construct keys.
Every key is built as described in Cache Keys, so invalidation follows from the notebook’s dataflow graph, including changes to UI elements and mo.state.
In-memory caching¶
The decorator @mo.cache memoizes a function.
Through Constructing the key, each call is keyed on the function’s code, arguments, and variables it uses outside its body.
Results stay in kernel memory, so a hit reads nothing from disk, but all are lost when the session ends.
This form suits values cheap to hold but wasteful to recompute, such as a function result reused whenever a slider moves.
@mo.cache keeps every result; the variant @mo.lru_cache keeps a bounded number, 128 by default, evicting the least recently used when full.
Comparison to functools. Python’s built-in functools.cache is not well-suited to reactive notebooks.
A reactive runtime re-runs a function’s defining cell whenever its code or inputs change, recreating the function with an empty functools cache.
Every stored result is therefore thrown away on every re-run of the defining cell, even when the change did not affect the function’s behavior.
Persistent caching¶
The decorator @mo.persistent_cache memoizes a function with the same keys but writes results to disk through the loaders of Storage and Loading, so they survive kernel restarts.
This lets a notebook restart without re-running expensive cells, and the WASM export (WASM portability) bundles its files.
Used as a context manager, with mo.persistent_cache(name="training"): caches a block of code inside a cell.
On a hit, the block does not execute: its variables are restored from disk, and its side effects do not happen.
Optional arguments choose the storage directory (save_path) and the loader (method="pickle" or method="lazy").
A third option, pin_modules, opts library versions into the key.
Automatic caching of every cell¶
The decorators and the context manager cache only what the author wraps.
marimo can also cache every cell automatically.
In this mode, the runtime computes each cell’s hash and checks the cache: on a hit it skips the body and restores its variables, and on a miss it runs the cell and saves the results.
A stored entry occasionally cannot be used: if one of its variables could not be serialized, the entry holds only a placeholder, and the cell re-runs along with any upstream cells needed to rebuild the missing value.
Turning on the cache_cells runtime option yields the automatic, cell-granular caching that Introduction argued reactive notebooks make possible: the whole notebook is cached and the author marks nothing.
Evaluation¶
Caching does not always save time. Every hit pays a fixed overhead: deriving the key, then loading the value. When the goal is portability, or a record of where results came from, rather than speed, that overhead does not matter. When the goal is to skip expensive recomputation, the overhead must be smaller than the cell body it avoids.
We validate the implementation by measuring three cost components — key derivation, value load, and value save — both separately and as end-to-end hit and miss paths.
Payloads are NumPy float64 arrays from 1 MB to 1 GB.
We compare six strategies: mo.cache, mo.persistent_cache with each of its two loaders, mandala’s decorated-function memoization, and diskcache in two forms, a memoizing decorator and a plain store with a fixed key.
The fixed-key form performs no key derivation at all, so it is a lower bound on hit time.
Each cost is measured over 10 runs after priming and a warmup, and we report the median.
Every persisted measurement is keyed on a host fingerprint (operating system, architecture, and Python version) and a methodology-version string, so no result is reused across hosts or after the harness changes.
End-to-end cache evaluation¶
A notebook user experiences the cache as a single delay: the time from editing one cell to the moment a downstream cell’s value is available in Python again. Panel (a) of Figure 3 reports that end-to-end time for six strategies. All six cluster together up to roughly 49 MB. The dashed line at 100 ms marks the threshold below which a response reads as instantaneous Card et al., 1991, and every persistent method stays under it across typical exploratory payload sizes.
The dotted curve in panel (a) shows when caching pays off. With hit rate , caching saves time when the cell body costs more than . On these payloads the measured miss overhead is comparable to the hit cost, so at the break-even body cost is roughly 10% above the hit curve.
Panel (b) decomposes the largest-payload hit into key derivation and value load, next to the overhead a miss adds (key derivation and value save).
mandala derives its key with joblib.hash, which serializes the value through pickle before hashing it Makelov, 2024.
marimo hashes the array’s contiguous bytes directly through Python’s buffer protocol, the content addressing of Constructing the key, with no serialization step.
On the Apple M4 Max used for the camera-ready figures, hashing is fast, and the extra pickle pass costs mandala roughly 3× end to end.
On a Linux x86-64 server, where memory copies are cheap relative to hashing, the pickle pass costs little, value load dominates instead, and the penalty shrinks to roughly 1.2×.
marimo’s value-load time matches that of the fixed-key diskcache form, which performs no key derivation, confirming that the gap comes from key derivation rather than from storage.
Panel (c) exposes per-call variance; it also shows diskcache.memoize failing once a payload exceeds its SQLite blob-size ceiling, so its largest sizes drop out of the sweep.

Figure 3:End-to-end cache evaluation on NumPy float64 payloads.
(a) Cache-hit latency versus payload size, on log-log axes.
The dashed line at 100 ms marks the interactive threshold Card et al., 1991, and the dotted curve is the break-even body cost at a 90% hit rate.
(b) Decomposition of a hit (key derivation plus value load) and a miss (key derivation plus value save) at the largest sweep size, measured on the real disk-backed paths.
(c) Per-method distribution of cache-hit samples at the largest size; diskcache.memoize drops out past its blob ceiling.
The host label reports the build host, the mandala-to-marimo ratio, and the cold-versus-cached cost of the figure’s own sweep.
The camera-ready version of this paper was evaluated on a MacBook Pro with an Apple M4 Max.
The measurements are produced by the paper’s own build, and the figure’s host label reports the measured mandala-to-marimo ratio, so a reproduction on different hardware shows its own number.
The stage-decomposition measurement times the real disk-backed paths on both sides: PickleLoader.load_cache and LazyLoader.load_cache for marimo, and joblib.load on a temp-file blob for mandala.
The load comparison therefore includes the cost of reading through the operating system’s file cache and of reconstructing the cache envelope, both of which a bare pickle.loads would skip.
Limitations and Discussion¶
The most salient limitations follow.
Library versions. By default, the key does not take library versions into account; the user must explicitly opt in (Using marimo’s cache). Upgrading a package can therefore serve stale hits. The portable WASM export must accept this gap, because pinning would bind keys to the export host.
Python versions. Because code is hashed as bytecode, keys are specific to the Python version (Constructing the key). Upgrading the interpreter therefore invalidates every cached result. Unlike a library upgrade, this failure is safe: the keys miss and the cells recompute. It is also why the browser export requires the exporting interpreter to match the browser’s Python version (WASM portability).
Mutation outside the graph. Mutations that bypass the dataflow graph — aliased mutation through a closure, or attribute writes on an object that cannot be content-addressed — can change a value without changing any key, so downstream cells can be served stale results. A suite of checks that runs on every build of this paper measures exactly this class of undetectable false positive, and Rex Zheng et al., 2025 probes the same boundary.
Cache tampering. Unpickling can execute arbitrary code, so loading from a cache that an attacker has modified could run a malicious payload. For the lazy loader, marimo mitigates this by signing caches: each manifest carries an Ed25519 signature that also covers the SHA-256 hash of every blob, and signatures are checked before any bytes are deserialized. The pickle loader has no such protection, and its caches should be loaded only from trusted sources.
Side effects. The cache is mostly blind to side effects such as file reads, network calls, and wall-clock queries.
marimo does provide a mechanism for tracking a side effect explicitly: the side effect is wrapped in a handle whose value is folded into the cell’s hash as if it were an external reference.
Two such handles exist today, mo.watch.file and mo.watch.directory, keyed on file content and directory listing respectively.
Randomness and wall-clock time could bind to similar lifetime-managed handles.
None of these limitations is fundamental to the approach. Future work can address each one by adding branches to the key dispatch.
Future work¶
Our evaluation and limitations point to four directions.
Cost-aware policy. End-to-end cache evaluation measured what a cache hit costs and what a cache miss adds. With those numbers available at runtime, marimo could apply the break-even rule automatically: decline to cache a cell whose body runs faster than the cache’s own overhead, and report the time each cached cell actually saved.
Expanded side effects. Limitations and Discussion described handles that fold a side effect’s value into a cell’s key. Handles for randomness (mo.random), network requests (mo.request), and time (mo.clock) would be straightforward additions. Whether tracking more side effects justifies the added interface surface remains an open question.
Richer storage formats. The lazy loader stores each value with a format-specific stub (Storage and Loading). A format for PyTorch tensors (.pt) and one for Apache Arrow tables (pyarrow.Table) are natural next steps. Cached cell results could also be reused by agentic workflows that drive long-running notebook sessions Manz et al., 2026.
Chain of trust for exports. The signing described in Limitations and Discussion protects a cache against tampering, but a shared or exported cache also requires the reader to know which public key to trust. Establishing that chain of trust, from the machine that produced an export to the reader’s browser session, remains open.
Two further questions remain open: a storage policy (eviction, per-codec footprint), and cross-session memoization aligned with recorded provenance Pimentel et al., 2017.
Conclusion¶
We have presented a caching mechanism for marimo, a reactive notebook for Python.
The cache key for a cell is built from the cell’s compiled body and the content-addressed values of its references, falling back to the producing cell’s hash when a reference cannot be addressed by content.
This construction preserves reactive determinism, survives superficial edits, and supports portable exports.
Our evaluation shows that a marimo cache hit performs comparably to existing scientific-Python memoizers while requiring no annotations from the user.
Finally, the cache participates in marimo’s static HTML/Pyodide WASM export, so users can share notebooks with precomputed results and models, as the export published at https://
Acknowledgments¶
Portions of this work were assisted by a generative AI tool (Claude, Anthropic). Claude was used to help develop and run the benchmark harness reported in the Evaluation. All benchmark code and results were reviewed, verified, and revised by the authors, who take full responsibility for the accuracy and integrity of the final content.
- Pimentel, J. F., Murta, L., Braganholo, V., & Freire, J. (2019). A Large-Scale Study About Quality and Reproducibility of Jupyter Notebooks. Proceedings of the 16th International Conference on Mining Software Repositories (MSR ’19), 507–517. 10.1109/MSR.2019.00077
- van der Plas, F., & Pluto.jl contributors. (2020). Pluto.jl: Simple Reactive Notebooks for Julia. JuliaCon 2020 talk. https://github.com/fonsp/Pluto.jl
- Bostock, M. (2017). A Better Way to Code. Medium. https://medium.com/@mbostock/a-better-way-to-code-2b1d2876a3a0
- Valim, J., & Livebook Team. (2020). Livebook: Interactive and Collaborative Code Notebooks for Elixir. https://livebook.dev/
- Victor, B. (2012). Inventing on Principle. Invited talk, CUSEC 2012. https://worrydream.com/InventingOnPrinciple/
- Agrawal, A., & Scolnick, M. (2023). marimo: An Open-Source Reactive Notebook for Python (latest) [Computer software]. 10.5281/zenodo.12735329
- Li, Z., Chockchowwat, S., Sahu, R., Sheth, A., & Park, Y. (2025). Kishu: Time-Traveling for Computational Notebooks. Proceedings of the VLDB Endowment, 18(4), 970–985. 10.14778/3717755.3717759
- Michie, D. (1968). “Memo” Functions and Machine Learning. Nature, 218(5136), 19–22. 10.1038/218019a0
- Koop, D., & Patel, J. (2017). Dataflow Notebooks: Encoding and Tracking Dependencies of Cells. 9th USENIX Workshop on the Theory and Practice of Provenance (TaPP 2017). https://www.usenix.org/conference/tapp17/workshop-program/presentation/koop
- Macke, S., Gong, H., Lee, D. J.-L., Head, A., Xin, D., & Parameswaran, A. (2021). Fine-Grained Lineage for Safer Notebook Interactions. Proceedings of the VLDB Endowment, 14(6), 1093–1101. 10.14778/3447689.3447712
- Macke, S. (2022). IPyflow: A Next-Generation, Dataflow-Aware IPython Kernel. https://github.com/ipyflow/ipyflow
- Mokhov, A., Mitchell, N., & Peyton Jones, S. (2018). Build Systems à la Carte. Proceedings of the ACM on Programming Languages, 2(ICFP), 1–29. 10.1145/3236774
- Dolstra, E., de Jonge, M., & Visser, E. (2004). Nix: A Safe and Policy-Free System for Software Deployment. Proceedings of the 18th USENIX Conference on System Administration (LISA ’04), 79–92. https://www.usenix.org/legacy/event/lisa04/tech/dolstra.html
- Dolstra, E. (2006). The Purely Functional Software Deployment Model [Phdthesis, Utrecht University]. https://edolstra.github.io/pubs/phd-thesis.pdf
- Greco, C., & Tagliabue, J. (2024). Reproducible Data Science over Data Lakes: Replayable Data Pipelines with Bauplan and Nessie. Proceedings of the Eighth Workshop on Data Management for End-to-End Machine Learning (DEEM@SIGMOD ’24). 10.1145/3650203.3663336