---
title: "Freeze & Caching"
guide-section: "Build & Deploy"
tags: [Build, Deployment, CI\/CD]
status: experimental
versions: ">=0.11"
---

# 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.

::: {.callout-tip}
## Choosing `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:

```{.yaml filename="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).

```{.yaml filename="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`:

```{.yaml filename="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

```{.bash filename="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

```{.default filename="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:

```{.bash filename="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:

```{.bash filename="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:

```{.bash filename="Terminal"}
great-docs freeze --info
```

```{.default filename="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:

```{.bash filename="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:

```{.bash filename="Terminal"}
great-docs freeze user_guide/benchmarks.qmd
```

Or wipe the entire cache for a full refresh:

```{.bash filename="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:

```{.yaml filename=".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.

::: {.callout-tip}
## Committing `_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:

```{.yaml filename="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>` |

::: {.callout-important}
## Dependency 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.

```{.default filename="Your project"}
my-package/
├── great-docs.yml
├── _freeze/              ← always here, at the top level
│   └── ...
├── user_guide/
│   └── benchmarks.qmd
└── ...
```

::: {.callout-note}
## You 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:

```{.default filename="_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`:

```{.yaml filename="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:

```{.python filename="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:

````{.yaml filename="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:

```{.python filename="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:

```{.default filename="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:

```{.default filename="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.

- [Use Freeze to Cache Expensive Notebooks](../recipes/use-freeze-cache.qmd): a recipe with more
detailed examples and CI patterns
- [Freeze Demo](../recipes/freeze-demo.qmd): a live page with frozen outputs you can inspect
- [Configuration](configuration.qmd): all `great-docs.yml` options including `pre_render`
- [Publishing](publishing.qmd): deploying your site to GitHub Pages, Netlify, or other hosts
