# GitHub Actions checkout slow: shallow and sparse clones

> A GitHub Actions checkout slow enough to notice is usually fetch-depth 0, LFS or submodules. What each input downloads, and the filter that helps.

Source: https://latchkey.dev/learn/speed/reduce-checkout-time-shallow-sparse-clones  
Updated: 2026-09-21

A GitHub Actions checkout slow enough to be worth fixing is almost never fixed by making the clone shallow, because `actions/checkout` is already shallow: its `fetch-depth` input defaults to 1. What makes a checkout slow is one of the inputs somebody turned on, and the two that cost the most are full history and Git LFS.

Open the workflow and read the `with:` block before you change anything. Every expensive thing a checkout can do is opt-in, and the defaults are cheap, so a slow checkout is a record of a decision somebody made rather than a property of the platform.

The inputs below are quoted from the action's own declarations rather than from a blog post about them, because the defaults have changed over the action's major versions and most of the advice in circulation predates the current ones.

## The defaults are already the fast ones

The action declares `fetch-depth` as "number of commits to fetch. 0 indicates all history for all branches and tags", with a default of 1. It declares `fetch-tags` as "whether to fetch tags, even if fetch-depth > 0", defaulting to false. `submodules` defaults to false and `lfs` defaults to false.

That has a direct consequence for most of the advice you will find on this topic: adding `fetch-depth: 1` to a workflow that does not set it changes nothing at all, because that is what it was already doing. If a checkout step is taking minutes, something in the block is overriding a default, and the useful work is finding which line.

One default is worth knowing for a different reason. `clean` defaults to true and is described as running `git clean -ffdx && git reset --hard HEAD` before fetching. On a GitHub-hosted runner the workspace is empty when the job starts, so that costs nothing. On a persistent self-hosted runner it is real work on a large tree, and it is the one input where the right answer differs between the two kinds of runner.

| Input | Declared default | What turning it on downloads | Who actually needs it |
| --- | --- | --- | --- |
| `fetch-depth` | 1 | Every commit on every branch at 0 | Versioning tools, changelog generators, code scanners |
| `fetch-tags` | false | All tags, even at a shallow depth | Release tooling that derives a version from tags |
| `lfs` | false | Every LFS object referenced by the checked-out tree | Jobs that actually read the large files |
| `submodules` | false | One more clone per submodule, recursively | Builds that compile a submodule |
| `sparse-checkout` | not set | Only the listed paths are written to disk | Monorepo jobs that build one package |
| `filter` | not set | Omits objects matching the filter until needed | Deep history without the blobs |

> Input names, descriptions and defaults read from [actions/checkout](https://github.com/actions/checkout) on 21 September 2026. The current major version is v7.

## fetch-depth: 0 is the usual culprit, and it has a better answer

Full history gets added for a real reason and then never revisited. Version calculators, changelog generators, monorepo task runners deciding what changed, and some code scanners all need more than one commit, so somebody sets `fetch-depth: 0` because it definitely works, and from then on every job in the repository downloads every commit on every branch.

The cheap fix is to fetch what the tool needs rather than everything. A tool that compares against the merge base needs the history between here and there, not the history of every branch that ever existed, and a depth of fifty or a hundred usually covers it. Where the depth is genuinely unbounded, deepen after the fact, so only the job that needs history pays for it.

The better fix is a blobless clone. The action exposes a `filter` input described as "partially clone against a given filter", and `blob:none` gives you the complete commit and tree graph with none of the file contents, fetching blobs on demand only when something actually reads a file. That is exactly the shape a history-reading tool wants, and on a repository with a long past and large files it is the single largest change available.

```.github/workflows/ci.yml
# the expensive default that spread across the repository
- uses: actions/checkout@v7
  with:
    fetch-depth: 0

# full commit graph, no file contents until something reads one
- uses: actions/checkout@v7
  with:
    fetch-depth: 0
    filter: blob:none

# or: shallow by default, deepened only in the job that needs it
- uses: actions/checkout@v7
- run: git fetch --deepen=100 origin ${{ github.base_ref }}
```

## Sparse checkout, and the input that silently overrides it

In a monorepo the clone is not the problem; writing the working tree is. `sparse-checkout` takes a list of patterns, one per line, and only those paths are materialised on disk, which turns a checkout of a hundred packages into a checkout of the two a job actually builds. `sparse-checkout-cone-mode` defaults to true, which is the fast matching mode and expects directory prefixes rather than arbitrary globs.

There is one behaviour here that will cost you an afternoon if you meet it by accident. The action documents `filter` as "partially clone against a given filter" and adds, in the same description, "overrides sparse-checkout if set". Setting both does not combine them: the filter wins and your sparse list is ignored. A workflow that adds `filter: blob:none` to a step that already had a sparse list will quietly start writing the whole tree again.

Pick one per job rather than both. Use sparse checkout when the working tree is the cost, and a blobless filter when the history is. If you genuinely need both, run the sparse configuration yourself after a filtered checkout rather than expecting the action to merge them.

```.github/workflows/ci.yml
# monorepo: write two packages, not a hundred
- uses: actions/checkout@v7
  with:
    sparse-checkout: |
      apps/web
      packages/ui

# do NOT add filter here: the action documents that it overrides sparse-checkout
```

## LFS and submodules are separate downloads with separate answers

Setting `lfs: true` fetches the LFS objects referenced by the commit you checked out, which on a repository holding binary assets can be larger than the entire Git history. The question to ask is per job rather than per repository: a linting job and a unit test job usually never open those files, and only the packaging job does. Turn it on in the one job that reads them.

Submodules are a clone each, and `recursive` is a clone per submodule per level. They also interact with authentication, because a submodule pointing at another private repository needs credentials the default token may not carry. If a job compiles nothing from a submodule, it does not need the submodule, and leaving the default in place is both faster and simpler.

The same reasoning applies to tags. `fetch-tags` exists because a shallow fetch does not bring them, and release tooling that derives a version from the tag list needs them. Nothing else does, and on a repository with thousands of tags the difference is measurable.

## Why this page has no timing of its own

A checkout time is a property of your repository, not of the platform: the number is set by how many commits you have, how large your blobs are, and how much of the tree the job writes. A figure measured on a repository we control would be a fact about that repository, and every reader would quite reasonably treat it as a prediction about theirs.

What travels between repositories is the method. Time the checkout step in your own logs before and after each change, one change at a time, and compare the step duration rather than the job duration so that a slower test suite does not hide a faster clone. [GitHub Actions slow to start](/learn/speed/github-actions-cold-start-and-setup-time) separates queue time from setup time and has measured numbers for the rest of the fixed cost, and [why is GitHub Actions slow](/learn/speed/why-is-github-actions-slow) puts the checkout in proportion against everything else in the job.

Fix the inputs first, then decide whether the remaining time is worth more work. On most repositories the checkout stops being interesting the moment `fetch-depth: 0` is either removed or filtered.

## FAQ

### Is actions/checkout shallow by default?

Yes. The action declares `fetch-depth` with a default of 1, meaning it fetches a single commit, and it does not fetch tags unless `fetch-tags` is set. Adding `fetch-depth: 1` to a step that did not set it has no effect, which is why that advice so often appears to do nothing.

### How do I keep full history without a slow checkout?

Use a blobless partial clone: keep `fetch-depth: 0` and add `filter: blob:none`. You get the whole commit and tree graph, which is what version calculators and diff-based tools read, and file contents are fetched only when something opens a file. On a repository with a long history and large files this is the largest single improvement available.

### Can I use sparse-checkout and filter together?

Not usefully. The action's own description of `filter` says it overrides `sparse-checkout` when set, so the sparse list is ignored and the full tree is written. Choose based on what is expensive: sparse checkout when the working tree is large, a blobless filter when the history is.

### Why is my monorepo checkout slow even at depth 1?

Because the cost is writing the working tree rather than fetching commits. A single commit of a large monorepo is still tens of thousands of files on disk. Use `sparse-checkout` with the directory prefixes the job builds, leave cone mode on its default of true, and the step writes only those paths.

## References

- [actions/checkout: every input, description and declared default (verified 2026-09-21)](https://github.com/actions/checkout)
- [Git documentation: partial clone and the blob:none filter (verified 2026-09-21)](https://git-scm.com/docs/partial-clone)
- [Git documentation: git sparse-checkout and cone mode (verified 2026-09-21)](https://git-scm.com/docs/git-sparse-checkout)
- [GitHub: hosted runner specifications, including runner disk size (verified 2026-09-21)](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
