Incremental Build

An incremental build reuses outputs from the previous build and recompiles only what changed. How the record works, and why a clean job rebuilds everything.

An incremental build is a build that reuses the outputs of the previous build and recompiles only the units whose inputs changed. It works by keeping a record of what produced each output, then comparing that record against the current sources so every unchanged unit can be skipped.

The record is the whole mechanism. On a developer machine it survives because the build directory stays on disk between builds. In GitHub Actions the job starts on a machine that is provisioned for that job and deleted when the job ends (GitHub-hosted runners reference, checked on 2026-08-13), so the next run has nothing to compare against and compiles the whole tree.

Definition

An incremental build has three parts, and losing any one of them turns the next build into a full build.

  1. Outputs from an earlier build, still on disk: object files, generated headers, compiled classes, linked binaries.
  2. A record of the inputs that produced each output. Depending on the tool this is the modification time of the output file itself, a separate fingerprint file written alongside it, or a hash stored in a cache directory.
  3. A staleness rule that reads the record and decides, per unit, whether to reuse the output or rebuild it.

Deleting the outputs forces a full compile. Deleting the record forces a full compile even when the outputs are all present, because the build system has no way to prove they are current and treats an unprovable output as stale.

The two staleness rules

Build systems answer the staleness question in one of two ways, and the difference decides how a build behaves after a fresh checkout.

Timestamp rules compare modification times. An output is current when every input that feeds it has a modification time older than the output. The comparison is cheap because the filesystem already stores the numbers, and it is exact only while the timestamps mean what the tool assumes they mean.

Content rules compare hashes. The tool hashes the source text, the compiler identity, and the flag set, then looks for an output stored under that hash. Modification times are irrelevant, so a file whose timestamp moved but whose bytes did not still resolves to the same entry.

The table below shows where each build system keeps its record and which rule it applies. Documentation links are the upstream project references, checked on 2026-08-13.

Build systemWhere the record livesStaleness rule
GNU makethe output files themselvesA target is rebuilt when any prerequisite has a newer modification time.
Ninja.ninja_log and .ninja_deps in the build directoryModification times plus the exact command line that produced the output, so a changed flag rebuilds.
Cargotarget/<profile>/.fingerprintOne fingerprint file per unit, holding source modification times, flags, and dependency versions.
Gothe build cache directory reported by go env GOCACHEA hash of the inputs, with modification times ignored entirely.
ccachethe cache directory reported by ccache --show-configA hash of the preprocessed source, the compiler, and the flags.
MSBuildthe Inputs and Outputs attributes declared on each targetThe newest input modification time against the oldest output modification time.

Why a fresh workspace breaks the timestamp rule

A checkout writes every file it creates with the current time, because the commit does not carry modification times and the tool has nothing else to write. A restored archive behaves the opposite way: tar sets each extracted file back to the modification time it had when the archive was written.

Put those two together in one job and the ordering inverts. Every source file is stamped with the moment the job started, while every restored object file carries a stamp from an earlier day. A timestamp rule reads that and marks the entire tree out of date, which is the correct answer to the question it was asked. Content rules are unaffected, which is why a Go build or a ccache-backed C build recovers from a restore while a make or Cargo build does not.

Example

This workflow builds a Rust crate on a clean job. Cargo writes its outputs and its fingerprint database into target/, and both live only as long as the machine does.

name: build
on: push

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: cargo build --release

Every push runs the same three operations: a fresh machine, a fresh clone, an empty target/. Cargo finds no fingerprint files, so it compiles every crate in the dependency graph and the workspace itself, then the machine is deleted along with everything it produced.

Now add a cache step so target/ survives, and trace what changes.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache@v4
        with:
          path: target
          key: ${{ runner.os }}-cargo-target-${{ hashFiles('Cargo.lock') }}
          restore-keys: ${{ runner.os }}-cargo-target-
      - run: cargo build --release

The three runs below start from an empty store and change one source file between run 2 and run 3.

Runtarget/ at the startSource modification timesWhat Cargo does
1emptyall set to the checkout timeCompiles every unit and writes a fingerprint per unit. The cache step saves target/.
2restored, fingerprints intactall set to this run's checkout timeEvery source is newer than the fingerprint recorded for it, so every unit is rebuilt despite the outputs being present.
3restored, one source editedall set to this run's checkout timeSame result as run 2. The edit is invisible, because the other files already looked edited.

Run 2 is the shape that surprises people. The outputs are on disk, the fingerprints are on disk, and the build still compiles everything, because the checkout moved the source timestamps forward past the record. A build directory that persists on the machine between runs avoids the inversion entirely: the sources keep the timestamps they had when they were last written, the outputs keep theirs, and the ordering the build system depends on holds.

FAQ

What is the difference between an incremental build and a cached build?

An incremental build reuses the outputs of the previous build in place, using a record the build system wrote next to them. A cached build copies files from a remote store into a fresh workspace. The second one restores the outputs, and it restores the record only when the cache path covers the directory holding it, which is why a restored tree can still compile from scratch.

Why does my build recompile everything even after a cache restore?

Most likely the staleness rule reads timestamps. A checkout writes every source file with the current time, while a restored output keeps the modification time it had when the archive was created. Every source then looks newer than the object built from it, so the build system correctly marks the whole tree out of date.

Is an incremental build safe for a release artifact?

Treat it as unsafe unless the build system keys on content rather than timestamps. Incremental state accumulates whatever earlier builds left behind, including outputs from source that no longer exists in the tree. Release and tagged builds are usually run from a clean tree so the artifact is reproducible from the commit alone.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.