Building¶
Reference for marimo-book's build pipeline, every CLI command, and
all opt-in features.
How a build works¶
marimo-book is a two-stage pipeline:
content/*.md + *.py + book.yml
↓ marimo-book preprocessor (validates config, walks TOC, renders pages)
↓
_site_src/
├── docs/
│ ├── intro.md ← copied from content/
│ ├── chapter1.md ← rendered from chapter1.py via `marimo export`
│ ├── stylesheets/extra.css
│ ├── javascripts/marimo_book.js
│ └── images/ ← assets copied verbatim
├── mkdocs.yml ← fully derived from book.yml
└── changelog.md ← optional, when include_changelog: true
↓ mkdocs build (Material theme + plugins)
↓
_site/ ← final static HTML, ready to deploy
The preprocessor never shells out to mkdocs; it just emits the
artifacts mkdocs can consume. This keeps the shell swappable —
zensical (Material's Rust successor) reuses
the same mkdocs.yml verbatim.
Build cache¶
marimo export ipynb re-executes every cell in a notebook from scratch
— the dominant cost for any book with non-trivial computation. To
avoid paying that cost on every rebuild, the preprocessor maintains a
content-addressed cache.
How it works
After each successful build, the preprocessor writes a manifest at
{book_root}/.marimo_book_cache/manifest.json recording, per .py
TOC entry: source mtime, source SHA-256, the staged output path, and
a timestamp. On the next build it consults the manifest:
| Check | If true | If false |
|---|---|---|
cache.version matches current schema |
continue | full miss (reset cache) |
marimo_book_version matches |
continue | full miss |
Hash of relevant book.yml fields matches |
continue | full miss |
| Per-entry: source mtime unchanged | HIT (skip render) | hash check |
| Per-entry: source hash unchanged | HIT (refresh mtime) | MISS — re-render |
book.yml fields that invalidate the cache: widget_defaults,
defaults, dependencies, launch_buttons, repo, branch, the
flattened toc. Fields that don't (palette, fonts, title, analytics)
only affect mkdocs.yml emission, which is always re-run.
Markdown TOC entries are not cached — Markdown render takes ~10 ms each, so the bookkeeping isn't worth it.
What the cache cannot detect (use --rebuild for these):
- Data files the notebook reads (
pd.read_csv("data/foo.csv")) env-mode dependency upgrades (pip install -U numpy)sandbox-mode PEP 723 dep changes inside the notebook (the notebook's own mtime catches some of these, but pinned versions on disk that change underneath you don't)- A
marimopackage upgrade (rare; bump the marimo-book version yourself or--rebuildif a notebook starts rendering wrong)
Inspecting the cache
Hand-edit at your peril; the next build will overwrite anything you
change. To reset: marimo-book clean (removes _site/, _site_src/,
and .marimo_book_cache/) or marimo-book build --rebuild (rebuilds
fresh + repopulates the cache in one step).
CLI commands¶
marimo-book new <directory>¶
Scaffold a fresh book.
Options
| Flag | Effect |
|---|---|
--force |
Write into an existing non-empty directory (may overwrite) |
The scaffold ships book.yml, content/intro.md, content/example.py,
a GitHub Pages workflow at .github/workflows/deploy.yml, a
.gitignore, and a starter README.
marimo-book build¶
One-shot static build. Emits _site/ ready to deploy.
marimo-book build # default: book.yml in cwd
marimo-book build -b docs/book.yml # explicit config path
marimo-book build -o public --strict # custom output, fail on warnings
marimo-book build --clean --sandbox # clean rebuild + force sandbox
Options
| Flag | Default | Effect |
|---|---|---|
-b, --book PATH |
book.yml |
Path to the book.yml config |
-o, --output PATH |
_site |
Output directory |
--strict |
off | Fail on warnings (broken in-tree links, missing files, etc.) |
--clean |
off | Remove _site/, _site_src/, and .marimo_book_cache/ before building (implies --rebuild) |
--rebuild |
off | Re-render every notebook regardless of cache state — use when data files or env-mode deps changed |
--sandbox / --no-sandbox |
follow book.yml |
Override the dependency mode |
--shell mkdocs\|zensical |
follow book.yml |
Which static-site generator consumes the staged tree (see below) |
Use --strict in CI. It surfaces issues that would otherwise be
silent on a successful build.
Use --clean when cutting a release. Otherwise stale files from
removed TOC entries linger in _site_src/ (intentional, for fast
incremental dev builds; not what you want in production).
marimo-book serve¶
Live-reload dev server.
marimo-book serve # http://127.0.0.1:8000/
marimo-book serve --port 9000 --no-watch
marimo-book serve --sandbox # slower iteration, but reproducible
Runs an initial build, then a watchdog observer re-runs the
preprocessor when files change under content/ or book.yml is
edited. mkdocs's livereload pushes the browser refresh.
Options
| Flag | Default | Effect |
|---|---|---|
-b, --book PATH |
book.yml |
Path to the book.yml config |
--host TEXT |
127.0.0.1 |
Dev-server bind address |
--port INTEGER |
8000 |
Dev-server port |
--no-watch |
off | Disable the source watcher (useful for debugging) |
--rebuild |
off | Cold-build the initial render (watcher rebuilds always honour the cache) |
--sandbox / --no-sandbox |
follow book.yml |
Override the dependency mode |
macOS browser-reload flake
On some macOS setups, mkdocs's browser auto-reload doesn't always fire. The build pipeline itself is reliable; if the browser doesn't refresh, hard-refresh (⌘-R).
marimo-book check¶
Validate book.yml and linked content without building. Fast — no
mkdocs invocation, no marimo export.
Catches: malformed book.yml, TOC entries pointing at missing files,
unsupported file types. Use as a pre-commit hook for fast feedback.
marimo-book clean¶
Remove build artifacts.
marimo-book clean # removes _site/, _site_src/, cache
marimo-book clean -o public # custom output dir
Optional features (opt-in via book.yml)¶
Each flag is opt-in (default off) and may require an extra:
| Flag | What it does | Extra |
|---|---|---|
social_cards: true |
Material's social plugin auto-generates per-page OpenGraph / Twitter card PNGs and injects the matching <meta> tags |
marimo-book[social] (also needs system libcairo2 libpango-1.0-0 libpangocairo-1.0-0) |
cross_references: true |
mkdocs-autorefs resolves [Heading text][] to whichever page contains that heading — the MkDocs analog of MyST {ref} |
None — bundled with marimo-book |
check_external_links: true |
htmlproofer HEAD-checks every <a href> and <img src> against the live web |
marimo-book[linkcheck] |
include_changelog: true |
Preprocessor copies CHANGELOG.md from the book root (or its parent) into the staged tree and appends a "Changelog" entry to the nav |
None |
pdf_export: true |
mkdocs-with-pdf renders the entire book through WeasyPrint into _site/pdf/book.pdf and adds a "Download PDF" link to the page footer |
marimo-book[pdf] (also needs the cairo + pango system libs above) |
social_cards — OpenGraph previews¶
Enable when you care about how the book looks when shared on social
media. Cards inherit your theme.palette.primary for the background
colour. ~5 s overhead per build for ~20 pages.
cross_references — autorefs¶
With cross_references: true, you can write:
…and [Anywidgets][] resolves to whichever page has # Anywidgets
as a heading. Lets you reorganise nav structure without breaking
inbound links.
check_external_links — htmlproofer¶
Slow (~1–3 s per outbound link). Keep off in CI for normal builds and
turn on only when cutting a release. Combined with
marimo-book build --strict, broken external links fail the build.
include_changelog — auto-publish CHANGELOG.md¶
Single source of truth: the CHANGELOG.md PyPI links to also becomes
a docs page. Looks first at book_dir/CHANGELOG.md, falls back to
book_dir.parent/CHANGELOG.md so the common docs/-subdir layout works
without extra config. Silent no-op when no CHANGELOG.md exists.
pdf_export — single-PDF download¶
Renders the entire book to one PDF. Adds a "Download PDF" link to the
footer of every page. Slow on large books (~30 s for ~50 pages); turn
off in serve and on in CI / for release builds.
The PDF inherits cover page metadata from your book.yml:
- Cover title ←
title - Cover subtitle ←
description - Author ←
authors[*].name - Copyright ←
copyright
Output lands at _site/pdf/book.pdf. Wire it into your deploy by
copying the whole _site/ directory as usual.
PDF builds in CI only
Local dev rarely needs the PDF. Set pdf_export: false in your
primary book.yml and override in CI:
…and tweak book.yml to read the env var. Or maintain a
book.ci.yml variant just for the deploy job.
shell — build with zensical¶
Zensical is the Rust/Python successor to
Material for MkDocs, by the same team. Because the preprocessor emits
plain Markdown + a mkdocs.yml that zensical reads natively, swapping
the shell is one line:
pip install 'marimo-book[zensical]'
marimo-book build # or: marimo-book build --shell zensical
marimo-book serve # zensical's dev server + the usual watcher
Verified against zensical 0.0.62 with this site: page bodies are
byte-identical to the mkdocs build, extra.css and the palette apply
unchanged (both zensical theme variants keep Material's DOM; marimo-book
pins classic), WASM islands hydrate and run, anywidgets / Plotly /
Altair render, autorefs and mkdocstrings work, and --strict is
honoured. The whole site builds in ~0.6 s.
What doesn't work yet. Zensical has no plugin-compatibility layer —
it only ships native rewrites of specific mkdocs plugins, and it
silently ignores the rest (the build still says "No issues found").
As of September 2026 that means these book.yml features have no effect
under shell: zensical:
| Feature | Upstream status |
|---|---|
social_cards |
in development |
blog.enabled (+ rss) |
in development (rss: planned) |
check_external_links |
not planned |
pdf_export |
not planned |
marimo-book check errors when any of them is combined with
shell: zensical, so a book can't quietly lose its blog. mkdocs
remains the default until those land; progress is tracked in
#105 and on
zensical's plugin compatibility page.
Where the output goes. Zensical refuses a site_dir outside its
project root (the directory holding mkdocs.yml), so the generated
config points it at _site_src/site/ and marimo-book build mirrors
the result to _site/ (or --output). Deploy workflows need no change.
Output layout¶
After marimo-book build:
your-book/
├── book.yml
├── content/ ← your sources (untouched)
├── _site_src/ ← preprocessor output (intermediate)
│ ├── docs/ ← staged Markdown + assets
│ └── mkdocs.yml ← generated from book.yml
└── _site/ ← final HTML (deploy this)
├── index.html
├── intro/index.html
├── chapter1/index.html
├── assets/ ← Material's CSS/JS
├── stylesheets/extra.css
├── search/ ← search index
├── sitemap.xml
└── pdf/book.pdf ← only if pdf_export: true
_site_src/ is intentionally preserved between builds — incremental
rebuilds in serve are much faster when the staged tree exists. To
force a wholly fresh build, use marimo-book build --clean.
Static reactivity¶
marimo-book can give static pages a real feel of interactivity for
marimo's discrete UI widgets — without a Python kernel at runtime. The
preprocessor re-executes the notebook once per widget value at build
time, ships the resulting cell outputs as a JSON lookup table, and a
small JS shim swaps the affected cells when the reader interacts with
the widget.
This is the kernel-free reactivity path for defaults.mode: static
(the default). WASM-rendered pages get native reactivity via Pyodide
and this whole pipeline is a no-op for them — the static fallback
works the same way either side of that line. See
WASM render mode below.
Opt in via book.yml¶
precompute:
enabled: true # off by default
max_values_per_widget: 50 # graceful skip if a widget has more
max_combinations_per_page: 200 # graceful skip on multi-widget pages
max_seconds_per_page: 60 # wall-clock budget for one page
max_bytes_per_page: 10485760 # 10 MB inline-table budget
exclude_pages: [] # ["content/heavy_chapter.py"]
All caps default to safe values that work for typical exploratory
notebooks. Over-cap = render static, log a warning — the build
doesn't fail unless --strict is passed.
What's a precompute candidate?¶
The widget IS the annotation. Authors write normal marimo code; the
choice between discrete and continuous widgets implicitly declares
candidacy. Notebooks stay portable — zero imports from marimo-book
in the .py source.
| Widget call | Precompute? |
|---|---|
mo.ui.slider(steps=[0, 1, 5, 10]) |
✅ explicit value list |
mo.ui.slider(0, 10, step=1) |
✅ explicit step |
mo.ui.slider(0, 10) (no step) |
❌ continuous, render static |
mo.ui.dropdown(options=["a", "b"]) |
✅ |
mo.ui.dropdown(options={"a": 1, "b": 2}) |
✅ keys enumerated |
mo.ui.switch() / mo.ui.checkbox() |
✅ two values |
mo.ui.radio(options=[...]) |
✅ |
mo.ui.range_slider(...) |
❌ deferred to v2 |
Widget created via non-literal call (make_slider(low, high)) |
❌ value set not statically extractable |
Continuous sliders and any widget the AST scanner can't statically resolve fall back to the existing static render — no surprises, no silent failures.
What's NOT in v1¶
- Multi-widget pages. A page with two precomputable widgets falls back to static for both, with a warning. The cross-product semantics
- JS shim for joint widgets are deferred to v2.
- Path Y subgraph re-execution. v1 re-runs the whole notebook per value; v2 will use marimo's dataflow graph to re-execute only the affected subgraph (10–100× speedup for notebooks with expensive imports / data loads).
mo.ui.range_slider. Two-handle widget with pair-valued state needs more design.
Demo¶
The Authoring → Static reactivity demo page in this book shows the
feature live. View its source on GitHub to see exactly how the author
wrote it — there's nothing marimo-book-specific in the .py.
When to use it (and when not to)¶
Good fit: - Educational notebooks with "tweak this parameter and see what happens" - Discrete dropdowns of options with cheap-to-compute outputs - Single-slider plot explorations with ≤50 values
Bad fit:
- Continuous sliders that need fine-grained interactivity (use
WASM mode)
- Notebooks where every cell is expensive (build time × N values).
Wrap the expensive cell in
mo.persistent_cache
so subsequent precompute runs hit the cache.
- Multi-widget cross-products with shared downstream cells (v2)
Build cost¶
- First export is the static fallback render — paid regardless.
- Each additional value = one full notebook re-execution. Use
max_seconds_per_pageto bound this; the orchestrator extrapolates after the first export and aborts if projected runtime exceeds. - The build cache (v0.1.0a4) interacts with precompute correctly:
toggling
precompute.enabledinvalidates affected pages.
Tuning caps for heavy notebooks¶
When a precompute opt-in page renders static instead of interactive
(e.g. the slider control is missing from the published page), the
build report's warnings: section will name the cap that tripped:
content/ICA.py: projected runtime (72.0s × 10 renders) exceeds
max_seconds_per_page (60s); rendered static.
Common patterns and the cap to bump:
| Symptom | Cap to raise |
|---|---|
| Notebook with expensive imports (nilearn, scikit-learn warm-up) | max_seconds_per_page |
| Each render emits a large HTML blob (whole-brain plot, big DataFrame) | max_bytes_per_page |
| Many widgets joint-grouped (cross-product blows up) | max_combinations_per_page |
| Single widget has more than 50 values | max_values_per_widget |
Concrete example for a neuroimaging tutorial whose ICA chapter takes ~7s per render and ships ~1 MB of brain HTML per value:
precompute:
enabled: true
max_seconds_per_page: 600 # 10 minutes, room for 80 renders × 7s
max_bytes_per_page: 50000000 # 50 MB, room for ~25 brain images
Caps apply per-page, so bumping them globally only costs build time on pages that actually exceed the defaults — fast pages keep their fast budget.
WASM render mode¶
For pages that need full Python reactivity — continuous sliders, arbitrary user input, real downstream re-execution — opt in per page:
Or for a whole book:
What it does. The preprocessor routes the page through marimo's
MarimoIslandGenerator.
At first paint the browser downloads marimo's frontend bundle (~5 MB
gzipped, jsdelivr CDN) plus Pyodide (~30 MB, lazy). Cells then become
natively reactive — sliders work continuously, every cell re-runs on
input change, no precompute caps apply.
When to use. Per-page opt-in is the recommended pattern: leave most
chapters static for instant first paint, flip mode: wasm only on
the few that genuinely need full reactivity.
When NOT to use. Compute-heavy notebooks fare badly under WASM —
joblib's parallel backend doesn't run efficiently in Pyodide, and big
NumPy/Pandas/scientific stacks have slow first-import in the browser.
For one-shot expensive computations,
mo.persistent_cache
on a static page is faster end-to-end.
Caveats. - First page load is heavy (~30 MB Pyodide download, cached after). - Not every Python package is in Pyodide's package set — check pyodide.org/packages.
Loading remote data. WASM pages can pull datasets over HTTP with no
server of their own. As of marimo 0.23.7, DuckDB reads CSV / Parquet /
JSON / GeoJSON from a URL inside mo.sql, SQL cells, and the
duckdb.read_* Python API — e.g.
SELECT * FROM read_csv('https://example.com/cars.csv'). Polars network
I/O (pl.read_csv(url) and friends) also works in WASM as of 0.23.5. So
a fully interactive chapter can load its own data with zero backend.
The Authoring → WASM demo page in this book is a working example.
ePub / other formats?¶
Not supported as a flag yet. Recipe via pandoc on the built site:
marimo-book build
pandoc _site/intro/index.html _site/chapter1/index.html ... \
-o book.epub \
--metadata title="My Book" \
--metadata author="Your Name"
If demand picks up, an [epub] extra is on the roadmap. File an
issue with your use case.
Build performance reference¶
Approximate timings on a modern laptop (M-series Mac, no sandbox mode):
| Operation | Time |
|---|---|
Parse book.yml, validate TOC |
<50 ms |
| Render one Markdown page | <10 ms |
marimo export for one notebook (no widgets) |
~500 ms |
marimo export for one notebook (heavy anywidgets) |
~2 s |
Full mkdocs build for ~20 pages |
~500 ms |
social_cards rendering |
~250 ms / page |
pdf_export rendering |
~600 ms / page |
check_external_links (per outbound URL) |
1–3 s |
sandbox mode adds ~5–10 s per notebook the first time (provisioning
a fresh uv env); subsequent runs hit the uv cache and are fast.
See Authoring → Dependencies for the full sandbox
walkthrough.