Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Hash all the things: Caching for fast notebook restarts

Abstract

We describe a caching mechanism for resumable sessions in marimo, a reactive Python notebook. Cache keys are derived recursively from the reactive DAG, content-addressing reference values and substituting parent-cell hashes where direct content addressing is not possible. The recurrence forms a Merkle structure that an edit invalidates at the subtree granularity. Cached values cross process and session boundaries through a lazy stub mechanism and participate in marimo’s static WASM/HTML export, so heavy computations and trained models can ship with a notebook to readers with a browser-embedded Python runtime. Empirically the cache lookup is as fast or faster than representative baselines on microbenchmarks of variably sized payloads. However, unlike other mechanisms, marimo’s caching is native to the reactive notebook, adds little user-facing overhead in its utilization, and allows for cross platform reuse.

Introduction

Notebooks underpin much of scientific Python, but most of them do not survive a clean rerun. In a survey of 1.4 million public Jupyter notebooks, only about a quarter re-execute top-to-bottom without raising Pimentel et al., 2019. Jupyter serializes a record of what the author ran in the past, but is not a description of what the notebook executes. Traditional notebooks like Jupyter, as essentially organized REPLs (Read-Evaluate-Print loops), suffer from out-of-order execution and hidden state that are not captured in the notebook’s artifacts, making reproducibility difficult.

Reactive notebooks mostly address this gap by treating cells, a unit of source code, as nodes in a dataflow graph. From the variables a cell uses, its references (refs), and the variables it binds, its definitions (defs), cell order is determined by data dependencies rather than by source order. Editing a cell clears stale memory and re-executes its dependents. Since the dependence relation is derived from refs and defs rather than from a runtime trace, this makes hidden state difficult and out-of-order execution impossible. Notable reactive notebooks include Pluto.jl Plas & Pluto.jl contributors, 2020, Observable Bostock, 2017, and Livebook Valim & Livebook Team, 2020, while IPyflow and nbsafety Macke et al., 2021Macke, 2022 retrofit something close onto Jupyter through runtime dependency inference and program slicing. The lineage descends 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’s state. marimo Agrawal & Scolnick, 2023 reinvents the reactive notebook for Python, and its cached resumption is the focus of this paper.

Traditional notebooks are flexible in that only part of the notebook needs to be evaluated to repopulate memory, but a reactive notebook, which parallels a dataflow pipeline, reruns top-to-bottom by construction. As a consequence, unless the user conditionally guards, the reactive notebook user pays for every expensive cell on every session. However, since a reactive notebook already knows what each cell depends on, expensive recomputation can be avoided if the cell body were assumed to be deterministic.

Caching for notebooks and Python is not new. IncPy modifies the CPython interpreter to memoize function calls automatically Guo & Engler, 2011; knitr caches literate-document chunks with hand-declared dependency chains Xie, 2015; jupyter-cache re-executes a notebook wholesale when any code cell changes Executable Books Project, 2020. mandala memoizes decorated calls inside a with storage: block Makelov, 2024, Kishu checkpoints whole sessions for time travel Li et al., 2025, ElasticNotebook migrates live state across machines Li et al., 2024, and diskcache provides a byte-keyed store at the bottom of the stack Jenks, 2016. Each asks the user to opt in at a different boundary. As later discussed, marimo’s reactive DAG produces an implied boundary on the cell level, and removes the cognitive overhead for users.

Concretely we target three properties. (a) Skip expensive recomputation when references and source are unchanged. (b) Preserve reactive determinism within a session under reordering and partial reruns. (c) Make cached artifacts transportable through marimo’s static WASM/HTML export. Out of scope are full session restoration in the sense of Li et al., 2025, distributed execution, and reproducibility of arbitrary Python notebooks Pimentel et al., 2019. We claim deterministic reuse between notebook sessions that follow marimo’s reactive principles.

Background and Related Work

Michie’s memo functions Michie, 1968 are the canonical outline of “function caching”. A cached function skips recomputation when an input-derived key matches a stored key, returning the stored value instead. Caching a cell requires the same key to reconstruct under the same conditions, which forces the question of what a cell’s “inputs” are.

A reactive notebook’s static derivation gives a graph that is stable across runs, and at runtime, the contents in memory. The key for a cached result must be a function of the graph the source defines, not of a particular execution trace, and the computed values at evaluation time. Cell-level dataflow tracking has an earlier antecedent in Koop & Patel, 2017, and Rex Zheng et al., 2025 probes the boundaries of reactivity in marimo specifically.

To build a key from a cell’s refs in marimo, we look to build systems for inspiration. Build Systems à la Carte Mokhov et al., 2018 decomposes a build system into a rebuilder (deciding when to rerun) and a scheduler (deciding the rebuild order). marimo’s caching is a content-hash rebuilder paired with a reactive scheduler, with no persistent build trace. We borrow the recursive-hash derivation lookup from Nix Dolstra et al., 2004Dolstra, 2006 and apply it to a reactive notebook instead of a static package graph, making marimo’s caching approach closer to Nix’s input-addressed model than to Shake’s verifying traces. The data-engineering analog is Bauplan/Nessie’s pipeline-stage hashing Greco & Tagliabue, 2024, and workflow engines persist the same discipline at task granularity. Nextflow’s -resume and Snakemake’s --cache key results on code, parameters, and input hashes Di Tommaso et al., 2017Mölder et al., 2021.

For existing scientific-Python memoization, mandala Makelov, 2024, the closest analog at this venue (SciPy 2024), memoizes calls inside a with storage: context using joblib.hash for content addressing and persists call provenance. Additionally diskcache Jenks, 2016 is the byte-keyed control where content addressing is the user’s responsibility, and joblib’s Memory Joblib Developers, 2024, scientific Python’s standard persistent memoizer, keys on pickled arguments per decorated function. Although there are other complementary systems, such as Kishu Li et al., 2025 and ElasticNotebook Li et al., 2024, that checkpoint and migrate notebook state, only mandala and diskcache are directly comparable to marimo’s caching mechanism, and we benchmark against them in Evaluation.

Cache Key Construction

For computational caching, false positive hits are unacceptable and false negatives merely undesirable. For a useful cache, marimo’s caching requires a stable key derivation that is sensitive to value changes and robust to superficial notebook edits. At runtime a marimo notebook exposes a reactive DAG over its cells together with the ref values bound in memory, so a naive approach may be to construct the cache key by hashing every refs’ value directly. However, this does not survive Python’s exposure of mutation and FFI. Pointers move under realloc, weakrefs report identity rather than content, and opaque C-extension objects expose neither a buffer nor a stable repr. Conversely, another candidate key construction is to hash the cell’s source bytes alone. This method also fails, as cells with side effects like reading the filesystem, network, wall clock, would be invisible to the key, generating false positives. Build systems face the same dilemma and substitute the producer when the artifact is opaque Dolstra et al., 2004; marimo’s cache key follows the same discipline, derived recursively over the reactive DAG. Each cell’s key depends on (a) the cell’s compiled body (bytecode rather than source text, so comments and formatting do not participate) (b) a content address for every reference the cell reads, and (c) the keys of the parent cells that own those references. To produce the cache lookup, the references that can be content-addressed are hashed directly, and the others are substituted with their producer’s key, which is recursively derived by the same cascade.

Key dispatch

Cache key construction.
Left: the per-ref dispatch, a three-way fallback into Pure (hash the value), ContentAddressed (hash the buffer), or ExecutionPath (substitute the producing cell’s H); the first match emits the per-ref key, and every per-ref key feeds one sha256 combiner with the cell’s compiled-body hash to produce H(c).
Right: the derivation over the full cell, abridged from BlockHasher.__init__ (marimo/_save/hash.py).

Figure 1:Cache key construction. Left: the per-ref dispatch, a three-way fallback into Pure (hash the value), ContentAddressed (hash the buffer), or ExecutionPath (substitute the producing cell’s HH); the first match emits the per-ref key, and every per-ref key feeds one sha256 combiner with the cell’s compiled-body hash to produce H(c)H(c). Right: the derivation over the full cell, abridged from BlockHasher.__init__ (marimo/_save/hash.py).

The key dispatch is a flat three-way fallback (Figure 1), whose right panel makes the recurrence over the full cell explicit.

Pure cells reference nothing outside their own body; the key reduces to the hash of the compiled cell. Stateful refs are values captured by marimo’s intentional notebook state, such as UI elements and mo.state. For caching to work, these values must be in a hashable form (Python primitives, frozen collections, or collections of other hashable values), and we hash them directly. ContentAddressed refs expose their bytes directly as Python primitives and frozen collections, or as buffer-protocol objects. These buffer-protocol objects are an important case for scientific computing and cover numpy ndarray and other objects advertising numpy’s array interface. For these types, the content hash is derived from the contiguous buffer without serialization, an idiom borrowed from joblib Joblib Developers, 2024 and mandala Makelov, 2024. ExecutionPath refs are not themselves directly hashable but are cell-owned, and their contribution is captured by the hash of their upstream parent cell, recursively determined by the same dispatch. A fourth outcome in the listing, ContextExecutionPath, covers references defined in the same cell as the cached block (the code preceding a mo.persistent_cache context manager) whose surrounding context is folded into the key. The decorators @mo.cache / @mo.persistent_cache apply the same dispatch to function call arguments, resolved at call time. As an aside, the streamlit cache_data / cache_resource distinction Streamlit Team, 2023 anticipates the data-vs-resource split that ContentAddressed and ExecutionPath resolve.

Parent-hash substitution

For each cell cc we record a hash H(c)H(c) at the end of the cell’s execution. When a downstream cell reads a ref rr produced by cc and rr is not directly addressable, we substitute H(c)H(c) for rr in the downstream key, which preserves reactive granularity and corresponds to the ExecutionPath branch in Figure 1. The downstream key invalidates exactly when the producing cell’s key invalidates, which is exactly when the reactive scheduler would re-run the downstream cell. We do not require every value to be hashable in itself, only that its producing cell be hashable. The substitution is a special case of Hughes’s lazy memo function model Hughes, 1985, where equality is by stored location rather than by deep value.

The recurrence builds a Merkle DAG Merkle, 1988 over the notebook. Each cell’s hash commits to the hashes of its parents, so an edit to one cell invalidates exactly the subtree it dominates. As a result, the rebuilder has a O(changed subtree)O(|\text{changed subtree}|) rehash cost since we never re-derive a cell’s hash if neither the inputs nor the cell body changed.

Figure 2 visualizes the recurrence on a four-cell PyTorch DAG codified below. The slider seeds a random input tensor, a model is constructed independently, and the forward pass binds them. Each branch of the key calculation is exercised. a is Pure, b is ContentAddressed via the tensor’s buffer, and c and d substitute H(parent) for the unhashable nn.Module called TinyNet.

Worked example of the recurrence on the four-cell DAG codified above.
Every hash and branch label is emitted by marimo’s hasher over a compiled cell graph at render time.
Moving the seed (t_0 \to t_1) invalidates a, b, and d (red edges); c stays cached because the seed is not among its refs.
Re-rendering with the same seed (t_1 \to t_2) leaves every hash fixed (green edges) and the rebuilder reuses every result.
The italic label under each box names the cascade branch the cell exercises.

Figure 2:Worked example of the recurrence on the four-cell DAG codified above. Every hash and branch label is emitted by marimo’s hasher over a compiled cell graph at render time. Moving the seed (t0t1t_0 \to t_1) invalidates a, b, and d (red edges); c stays cached because the seed is not among its refs. Re-rendering with the same seed (t1t2t_1 \to t_2) leaves every hash fixed (green edges) and the rebuilder reuses every result. The italic label under each box names the cascade branch the cell exercises.

Storage and Loading

Cache keys identify values, but a notebook does not always need the value until performing new computations or renderings. For instance, a downstream cell may take a reference and forward it to a third cell without inspecting it. Storage and loading are therefore decoupled from lookup. The default PickleLoader in marimo serializes the full Cache envelope as one pickle blob, re-materializing every def eagerly on lookup.

The newer, opt-in mechanism, the LazyLoader writes a JSON manifest of per-def references alongside individual blob files. The advantage of the lazy loader is that values can be hydrated on demand through a stub mechanism. The lazy loader will defer loading a value until it is needed for a computation or rendering. The store exposes a single ReferenceStub protocol with one load() method and codec-specific subclasses for pickle, joblib, numpy .npy, and Arrow. The value is deserialized on the stub’s first access.

WASM portability

Cache artifacts can also be computed for marimo’s static HTML/Pyodide WASM bundle exports. Exporting with marimo wasm-html --execute bundles the resulting manifests and blobs into a standalone html dump. Opening this content in the reader’s browser re-derives the same keys and rehydrates each value on first access, exactly as in an interactive session. Scientific articles (such as this one), general-audience blog posts, and educational materials benefit from this by shipping heavy computations and trained models with the notebook to readers with a browser-embedded Python runtime.

The export ships only the values and user code that produced them, not external libraries. However, a cell may still depend on packages the browser cannot import, as long as the values it defines serialize to a portable codec. As a demonstration (a live export is published at https://dmadisetti.github.io/scipy_proceedings/demo/), the export host trains a PyTorch model, exports it to ONNX bytes behind a small runtime wrapper, and slices a prediction sweep into numpy arrays. In the browser, where import torch is currently not possible, the arrays and the wrapper restore through their codecs, and a custom stub rebinds the ONNX bytes to onnxruntime-web.

Evaluation

In terms of performance, caching is a conditional win. The cache pays a fixed performance overhead on every hit (key derivation plus value load). If the goal of caching is portability and provenance rather than speed, then the time penalty is inconsequential. However, if the goal is to skip expensive recomputation, then the cache must pay back its overhead by avoiding a more expensive cell body. Although not currently implemented, a future extension of the cache could track hit rates and accumulated savings over time to make that tradeoff explicit, skipping the cache when it would be a net performance loss.

We validate the implementation by measuring three cost components (key derivation, value load, value save) separately and as end-to-end hit and miss paths on numpy float64 payloads from 1 MB to 500 MB, bracketed by representative baselines: mandala’s decorated-function memoization and diskcache’s byte-keyed store (the no-derivation floor).

End-to-end cache evaluation

Notebook users observe the wall time from “edit upstream” to “downstream value bound in Python.” Panel (a) of Figure 3 reports that composite metric across six strategies. All cluster up to roughly 49 MB. The 100 ms dashed line marks the threshold below which a response reads as instantaneous Card et al., 1991 and every persistent method holds it across typical exploratory payloads.

The dotted curve in panel (a) prices the miss path. With hit rate pp, caching pays when the cell body costs more than Thit+1pp(Tkey+Tsave)T_{hit} + \frac{1-p}{p}\,(T_{key} + T_{save}). Because the measured miss overhead is comparable to the hit cost on these payloads, the break-even body cost at p=0.9p = 0.9 rides roughly 10% above the hit curve.

Panel (b) decomposes the largest-payload hit into key derivation and value load, alongside the overhead a miss adds (key derivation and value save). mandala derives its key via joblib.hash, which serializes through pickle before hashing Makelov, 2024; marimo’s data_to_buffer views the ndarray’s contiguous bytes through Python’s buffer protocol and hashes them directly. On the Apple M4 Max used for the camera-ready figures, hashing is fast and the pickle pass costs mandala roughly 3× end to end; on a Linux x86-64 server, memory bandwidth makes the pickle pass nearly free, value load dominates instead, and the penalty compresses to roughly 1.2×. marimo’s value load tracks the diskcache (fixed key) floor, isolating the structural gap in key derivation rather than storage. Panel (c) exposes per-call variance.

End-to-end cache evaluation on numpy float64 payloads.
(a) Cache-hit latency vs payload size, log-log; the 100 ms dashed line marks the interactive threshold , the dotted curve the break-even body cost at a 90% hit rate.
(b) Hit (key derivation + value load) and miss (key derivation + value save) decomposition at the largest sweep size, on the real disk-backed paths.
(c) Per-method distribution of cache-hit samples at the largest size; diskcache.memoize fails outright past its SQLite blob ceiling, and failed sizes drop out of the plot.
The host label carries the build host, the mandala/marimo ratio, and the cold-vs-cached cost of the figure’s own sweep.

Figure 3:End-to-end cache evaluation on numpy float64 payloads. (a) Cache-hit latency vs payload size, log-log; the 100 ms dashed line marks the interactive threshold Card et al., 1991, the dotted curve the break-even body cost at a 90% hit rate. (b) Hit (key derivation + value load) and miss (key derivation + value save) decomposition at the largest sweep size, on the real disk-backed paths. (c) Per-method distribution of cache-hit samples at the largest size; diskcache.memoize fails outright past its SQLite blob ceiling, and failed sizes drop out of the plot. The host label carries the build host, the mandala/marimo ratio, and the cold-vs-cached cost of the figure’s own sweep.

The camera-ready version of this paper is evaluated on a MacBook Pro with an Apple M4 Max, and the figure caption carries the per-host ratio so that any reproduction shows its own number. The stage-decomposition measurement times the real disk-backed paths on both sides, exercising PickleLoader.load_cache and LazyLoader.load_cache for marimo and joblib.load on a temp-file blob for mandala, so the load comparison includes the page-cache and envelope-reconstruction costs that a primitive pickle.loads skips.

Limitations and Discussion

There are some notable limitations to this methodology, this is a non-exhaustive list of the most salient ones. (1) Library versions do not enter the key unless pin_modules=True, so an environment upgrade can serve stale hits; the portable WASM export must accept this gap, since pinning would bind keys to the export host; (2) mutable refs that bypass the DAG — alias mutation through a closure, attribute writes on a non-addressable object — can still poison downstream cells; the invariance battery measures exactly this undetectable false positive (Rex Zheng et al., 2025 probes the same boundary); (3) Cache tampering is possible under the current scheme: since unpickling can lead to arbitrary code execution, loading from a poisoned cache could execute a malicious payload; (4) The cache is mostly blind to side effects (file reads, network calls, wall-clock queries). However, a methodology to track side effects is implemented by folding back an associated side-effect value as a handle whose result contributes to the cell-level hash as if it were an external reference. Two constructors are currently provided, mo.watch.file and mo.watch.directory, keyed on content and listing; randomness or wall-clock time could bind to similar lifetime-managed handles.

These limitations are not fundamental to the approach, and future work could address them by extending the key construction with additional branches in the dispatch.

Future work

Three lines follow from the measured boundaries. Cost-aware policy: with the write path measured, the break-even rule can run online — refuse to cache bodies cheaper than their own overhead, and track realized savings per cell. Expanded Side-effects: creating mo.random, mo.request, and mo.clock would all be relatively easy extensions to the API, but the surface-area trade-off of tracking more side effects is an open question. Richer codecs: landing the measured .pt tensor codec and a pyarrow.Table Arrow path, plus persistent cell-result reuse for agentic workflows driving long-running sessions Manz et al., 2026. Cache poisoning mitigation: the lazy cache could export an Ed25519-signed result and check each blob against its signed SHA-256 before it is deserialized, requiring a chain of trust from the export host to the reader’s session. Storage policy (eviction, per-codec footprint) and provenance-aligned cross-session memoization Pimentel et al., 2017 remain open.

Conclusion

We have presented a computational caching mechanism for marimo, a reactive notebook system, that builds cache keys from the compiled cell body and the content-addressed values of its references, with a fallback to parent-cell hashes for unhashable references. This approach preserves reactive determinism, survives superficial edits, and supports portable exports. Moreover, it’s performant and provides a practical speedup for users executing expensive computations. Our evaluation demonstrates that marimo’s caching mechanism is comparable to scientific-Python memoization approaches, but has the benefit of requiring no additional user effort. Finally, we demonstrate that the caching mechanism is portable to static HTML/Pyodide WASM exports, allowing users to share notebooks with precomputed results and models, as shown by the live export published at https://dmadisetti.github.io/scipy_proceedings/demo/.

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.

References
  1. 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
  2. van der Plas, F., & Pluto.jl contributors. (2020). Pluto.jl: Simple Reactive Notebooks for Julia. JuliaCon 2020 talk. https://github.com/fonsp/Pluto.jl
  3. Bostock, M. (2017). A Better Way to Code. Medium. https://medium.com/@mbostock/a-better-way-to-code-2b1d2876a3a0
  4. Valim, J., & Livebook Team. (2020). Livebook: Interactive and Collaborative Code Notebooks for Elixir. https://livebook.dev/
  5. 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
  6. Macke, S. (2022). IPyflow: A Next-Generation, Dataflow-Aware IPython Kernel. https://github.com/ipyflow/ipyflow
  7. Victor, B. (2012). Inventing on Principle. Invited talk, CUSEC 2012. https://worrydream.com/InventingOnPrinciple/
  8. Agrawal, A., & Scolnick, M. (2023). marimo: An Open-Source Reactive Notebook for Python (latest) [Computer software]. 10.5281/zenodo.12735329
  9. Guo, P. J., & Engler, D. (2011). Using Automatic Persistent Memoization to Facilitate Data Analysis Scripting. Proceedings of the 2011 International Symposium on Software Testing and Analysis (ISSTA ’11), 287–297. 10.1145/2001420.2001455
  10. Xie, Y. (2015). Dynamic Documents with R and knitr (2nd ed.). Chapman & Hall/CRC. 10.1201/b15166
  11. Executable Books Project. (2020). jupyter-cache: A defined interface for working with a cache of executed Jupyter notebooks. https://github.com/executablebooks/jupyter-cache
  12. Makelov, A. (2024). Mandala: Compositional Memoization for Simple & Powerful Scientific Data Management. Proceedings of the 23rd Python in Science Conference (SciPy 2024). 10.25080/JHPV7385
  13. 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
  14. Li, Z., Gor, P., Prabhu, R., Yu, H., Mao, Y., & Park, Y. (2024). ElasticNotebook: Enabling Live Migration for Computational Notebooks. Proceedings of the VLDB Endowment, 17(2), 119–133. 10.14778/3626292.3626296
  15. Jenks, G. (2016). DiskCache: Disk and File Backed Cache for Python. https://github.com/grantjenks/python-diskcache