Freeze & Caching

When your documentation includes executable code cells, you don’t want to re-execute them on every build if nothing has changed. The freeze feature caches execution outputs and reuses them across builds, cutting build times dramatically.

Great Docs enables freeze: auto by default: code cells are only re-executed when their source .qmd file changes. On a subsequent build where nothing has changed, execution is skipped entirely and cached outputs are reused. The freeze cache is also persisted back to your project root automatically after each build.

This guide covers how freeze works under the hood, how to customize it for individual pages, how to use the great-docs freeze CLI command to manage your cache, and how the workflow fits into CI/CD pipelines.

How Freeze Works

The freeze mechanism stores the rendered output of executable code cells in a _freeze/ directory. When a page is frozen:

  • freeze: auto: the page is only re-executed when its source code changes. If the .qmd file hasn’t changed since the last execution, the cached output is reused.
  • freeze: true: the page is never re-executed during a project render. You must explicitly render it to update its outputs.
TipChoosing auto vs true

Use freeze: auto for most pages. It strikes the right balance by re-executing only when the source changes. Use freeze: true when you need absolute control: pages with non-deterministic output (random seeds, API calls), pages requiring hardware you only have locally (GPUs, licensed software), or pages where accidental re-execution could produce confusing diffs. With true, the only way to update the cache is an explicit great-docs freeze.

Default Behavior

Since freeze: auto is the default, you don’t need to configure anything for freeze to work. On your first build, all executable cells run and their outputs are cached in _freeze/. On subsequent builds, only pages whose .qmd source has changed are re-executed.

After each successful build, Great Docs automatically copies _freeze/ from the build directory back to your project root so it persists across builds (since the build directory is recreated each time).

Page-Level Overrides

While all executable pages are frozen by default, you can override the behavior for individual pages. To force a page to always re-execute (useful for pages that display live data or current status), add freeze: false to its frontmatter:

user_guide/live-status.qmd
---
title: "Live Status"
freeze: false
---

You can also use freeze: true on specific pages to lock them completely (so they are never re-executed during a project render, even if their source changes). The only way to update them is an explicit great-docs freeze <page>. This is useful for pages with non-deterministic output (random seeds, API calls) or pages requiring special hardware (GPUs, licensed software).

user_guide/benchmarks.qmd
---
title: "Benchmarks"
freeze: true
---

Great Docs normalizes the shorthand freeze: into the nested form that Quarto requires (execute: freeze:) automatically during the build.

Disabling Freeze

If you want all pages to re-execute on every build, set freeze: false in great-docs.yml:

great-docs.yml
freeze: false

How the Cache Persists

You might wonder: if Great Docs recreates the build directory from scratch on each build, how does the freeze cache survive? Great Docs handles this transparently with a two-step round-trip:

  1. Before rendering: a built-in pre-render script copies _freeze/ from your project root into the build directory
  2. After rendering: Great Docs copies the updated _freeze/ from the build directory back to your project root

You don’t need to create or manage any scripts; it’s all handled internally. This also works with versioned builds, where each version’s freeze cache is collected and merged back to the project root.

The great-docs freeze Command

The great-docs freeze command is your primary tool for managing frozen page outputs. It handles the full lifecycle: executing code, capturing outputs, and persisting the cache to a location you can commit. You never need to manually copy files or interact with the freeze internals directly.

Freeze a Page

Terminal
great-docs freeze user_guide/benchmarks.qmd

This:

  1. prepares the build directory (ensuring file transformations match a full build)
  2. renders the specified page(s), always executing code regardless of cache state
  3. copies updated _freeze/ entries back to your project root
  4. prints a ready-to-use git add + git commit command
Example output
Preparing build directory...

  Rendering user_guide/benchmarks.qmd → user-guide/benchmarks.qmd ...
  ✓ user_guide/benchmarks.qmd

Persisting _freeze/ → _freeze/
  Updated 3 cached file(s)

To commit the updated freeze cache:
  git add _freeze/
  git commit -m "Update freeze cache for user_guide/benchmarks.qmd"

The suggested git commands at the end make it easy to commit the cache in a single copy-paste step.

Freeze Multiple Pages

You can pass any number of pages in a single invocation. Each page is rendered and its outputs are persisted together, so you only need one git add _freeze/ afterwards:

Terminal
great-docs freeze user_guide/benchmarks.qmd user_guide/mcmc-demo.qmd

Full Refresh with --clean

Sometimes you want to start completely fresh. For example, after a major dependency upgrade that changes plot styling or output format. The --clean flag deletes the entire _freeze/ cache before re-executing:

Terminal
great-docs freeze --clean user_guide/benchmarks.qmd user_guide/sampling.qmd

This deletes and regenerates only the entries for the specified pages, leaving any other cached pages intact.

Check Freeze Status with --info

Not sure which pages are frozen or whether they’ve been cached yet? The --info flag gives you a quick dashboard:

Terminal
great-docs freeze --info
Example output
  Project freeze: auto (all executable pages)
  Freeze cache:   _freeze/

  3 cached page(s):

  ✓  recipes/freeze-demo
       frozen at 2026-05-06 13:26:41
  ✓  reference/tbl_explorer
       frozen at 2026-07-22 01:15:23
  ✓  user-guide/writing-docstrings
       frozen at 2026-07-22 01:15:24

  3 page(s) cached

  ℹ To re-freeze a stale page: great-docs freeze <page>

The output shows the project-level freeze setting, any per-page overrides, and all cached entries from _freeze/ with their timestamps. This is useful for identifying pages that may benefit from a re-freeze after updating dependencies or input data.

Custom Persist Location

By default the cache is written to _freeze/ at your project root. If your project layout requires a different location (for example, a monorepo where docs live in a subdirectory), use --freeze-dir to override:

Terminal
great-docs freeze user_guide/benchmarks.qmd --freeze-dir docs/_freeze

The build will look for the cache in the specified directory when restoring.

Complete Workflow

Since freeze is enabled by default, the workflow is straightforward:

First Build

Run great-docs build as normal. All executable cells run and their outputs are cached in _freeze/ at your project root. No configuration needed.

Subsequent Builds

On subsequent builds, only pages whose .qmd source has changed are re-executed. Everything else loads from the cache.

Updating Frozen Pages

When you update a dependency that changes output (e.g., a new package release that fixes a bug in a rendered table), the source hash won’t detect this. Use great-docs freeze to explicitly re-execute:

Terminal
great-docs freeze user_guide/benchmarks.qmd

Or wipe the entire cache for a full refresh:

Terminal
great-docs freeze --clean user_guide/benchmarks.qmd user_guide/sampling.qmd

In CI

The great-docs setup-github-pages command generates a workflow with built-in freeze caching. The cache is stored using actions/cache and keyed on both your dependency lockfile and .qmd content:

.github/workflows/docs.yml
- name: Restore freeze cache
  uses: actions/cache@v4
  with:
    path: _freeze/
    key: freeze-${{ hashFiles('**/uv.lock', ...) }}-${{ hashFiles('**/*.qmd') }}
    restore-keys: |
      freeze-${{ hashFiles('**/uv.lock', ...) }}-
      freeze-

This means:

  • Same dependencies + same .qmd files → full cache hit, no re-execution
  • Same dependencies + changed .qmd files → partial hit, only changed pages re-execute
  • Updated dependencies → cache miss, all pages re-execute with fresh outputs

GitHub Actions caches are branch-scoped, so PR branches benefit from main’s cache but never pollute it.

TipCommitting _freeze/ works too

You can also commit _freeze/ directly to your repository. Since actions/checkout restores it as part of the repo, Great Docs picks it up automatically (no actions/cache step needed). This is the simplest approach and gives maintainers direct control over what’s cached.

Choose one approach, not both. If you commit _freeze/ to your repo, do not also use the actions/cache step. The cache can overwrite your freshly committed freeze data with stale entries from a previous run, causing pages to re-execute unnecessarily.

Mixing Frozen and Fresh Pages

With the default freeze: auto, all executable pages are cached. Pages without executable code cells are unaffected (i.e., they always render fresh). To force a specific executable page to always re-execute, override it with freeze: false in its frontmatter:

a-page-that-must-always-run.qmd
---
title: "Live Status"
freeze: false
---

This is useful for pages that pull live data or display current status information.

When to Refresh the Cache

With freeze: auto, pages are automatically re-executed when their source .qmd changes. But some triggers aren’t captured by a source hash:

Trigger Automatic? Action
Source code of frozen page changes Yes Handled by freeze: auto
Package version update (bugfixes, new output) No great-docs freeze <page>
Input data files change No great-docs freeze <page>
Dependency updates (new plots, styling) No great-docs freeze --clean <pages>
Full refresh needed No great-docs freeze --clean <pages>
ImportantDependency updates and stale outputs

When you update a package that your examples depend on (e.g., a new release that fixes a rendering bug or deprecates a function), the cached outputs will be stale even though the .qmd source hasn’t changed. Run great-docs freeze --clean to re-execute with the updated dependency.

In CI, the generated workflow keys the cache on your lockfile hash, so dependency updates automatically trigger a full re-execution.

Use great-docs freeze --info at any time to check which pages are cached and when they were last frozen.

Where Does _freeze/ Live?

The _freeze/ directory always lives at your project root, the same directory as great-docs.yml. You never need to create it manually; great-docs freeze generates it for you and places it in the right spot.

Your project
my-package/
├── great-docs.yml
├── _freeze/              ← always here, at the top level
│   └── ...
├── user_guide/
│   └── benchmarks.qmd
└── ...
NoteYou don’t need to look inside _freeze/

The contents of _freeze/ are managed. Treat it as an opaque cache. You’ll never need to edit, inspect, or understand the JSON files inside. The only operations you perform on it are:

  • git add _freeze/: commit it so CI and collaborators get the cache
  • great-docs freeze --clean <pages>: wipe and rebuild when you need a fresh start

Think of it like a compiled artifact: you commit it for reproducibility, but you regenerate it with great-docs freeze rather than editing it by hand.

What’s Actually Inside (for the curious)

The directory mirrors your site structure with one JSON file per frozen page:

_freeze/ structure
_freeze/
├── site_libs/                # Shared JS/CSS libraries (e.g., clipboard.min.js)
├── user-guide/
│   └── benchmarks/
│       └── execute-results/
│           └── html.json     # Cached cell outputs + content hash
└── recipes/
    └── mcmc-demo/
        └── execute-results/
            └── html.json

Each html.json contains the rendered cell outputs and a hash of the source file. This hash is compared against the current .qmd. If they match, the cached output is used; if they differ (i.e., perhaps you edited the source), the page is re-executed. These files are typically small (a few KB to a few MB depending on plot complexity).

Advanced Configuration

The freeze feature works out of the box for most projects. The options below let you customize the workflow for more complex setups, such as projects that need data generation before rendering or monorepos with non-standard directory layouts.

Additional Pre-Render Scripts

Pre-render scripts run once before any page rendering occurs. They are useful for tasks that prepare shared data or assets that your .qmd pages depend on (e.g., downloading a dataset, generating fixture files, or validating inputs). These scripts don’t interact with individual page authoring directly; they simply ensure that prerequisites are in place before the render starts.

Each script is run with the build directory as the working directory. This means scripts can create files directly into the build tree where pages will pick them up. If a script exits with a non-zero exit code, the build fails immediately with an error, which is useful for enforcing preconditions.

If you have pre-render scripts of your own, add them via the pre_render key in great-docs.yml:

great-docs.yml
pre_render:
  - scripts/generate-data.py
  - scripts/validate-inputs.py

Great Docs runs these scripts in order before rendering. The built-in freeze cache restore always runs first (automatically), followed by your scripts. You don’t need to include the restore script in this list.

For example, we could generate a data file for pages to read:

scripts/generate-data.py
"""Download latest metrics and write them where pages can find them."""
import json
import sys
from pathlib import Path

# CWD is the build directory; assets/data/ is accessible from pages
output = Path("assets/data/metrics.json")
output.parent.mkdir(parents=True, exist_ok=True)

try:
    # Your data-fetching logic here
    metrics = {"downloads": 142_000, "stars": 3_200}
    output.write_text(json.dumps(metrics, indent=2))
except Exception as e:
    print(f"ERROR: Failed to generate metrics: {e}", file=sys.stderr)
    sys.exit(1)  # Non-zero exit fails the build

A page can then read from that path:

user_guide/dashboard.qmd
---
title: "Project Metrics"
---

```{python}
import json
from pathlib import Path

metrics = json.loads(Path("assets/data/metrics.json").read_text())
print(f"Downloads: {metrics['downloads']:,}")
```

As another example, we could validate that required files exist:

scripts/validate-inputs.py
"""Fail the build early if expected input files are missing."""
import sys
from pathlib import Path

required = [
    Path("assets/data/benchmark-results.csv"),
    Path("assets/data/changelog-entries.json"),
]

missing = [str(p) for p in required if not p.exists()]
if missing:
    print("Build aborted. Missing required files:", file=sys.stderr)
    for m in missing:
        print(f"  - {m}", file=sys.stderr)
    sys.exit(1)

This pattern is helpful when frozen pages depend on external data files that must be present but aren’t generated by the freeze process itself.

Build Log

The build log includes a step showing freeze cache status:

Build log excerpt (Step 15)
━━ Step 15/18 ─ Prepare freeze cache ━━━━━━━━━━━━━━━━━━━━
   ✔ Freeze cache will be restored during render  <0.1s

After a successful build, the log also confirms the cache was persisted:

Build log excerpt (after render)
      Saved freeze cache (42 files)

This makes it easy to confirm at a glance that freeze is working.

Next Steps

Freeze eliminates re-execution overhead for expensive pages, making builds fast and CI pipelines reliable. Once your cache is committed, collaborators and CI runners can build the full site without needing your computational environment.