Speeding Up Git LFS Checkouts in GitHub Actions

Git LFS is slow in GitHub Actions because lfs true pulls every object the ref points at. Fetch only the paths the job reads and price the difference.

Git LFS checkouts are slow in GitHub Actions because lfs: true downloads every LFS object the checked-out ref points at, on every job in the run, regardless of which files that job opens. The fix is to check out pointer files, then pull only the paths the job reads with git lfs pull --include, which turns a whole-repository transfer into a subset transfer.

This page covers where the time goes in an LFS checkout, the workflow change that scopes the download, the configuration that keeps the scope stable across jobs, and a time model that prices a full LFS checkout against a selective fetch at a stated repository size. It sits under the guide to speeding up GitHub Actions.

Diagnosis

A plain checkout moves one thing: packfiles of git objects. An LFS checkout moves two, and the two scale on different axes.

Pointer resolution. Every LFS-tracked path is stored in git as a small text pointer carrying an object ID and a size. Turning pointers into real files runs the LFS smudge filter across the working tree and issues batch requests to the LFS server to resolve download URLs for those object IDs. This half scales with the count of tracked paths. A repository with 41,000 tracked assets pays 41,000 pointer resolutions whether the objects behind them are 4 KB or 400 MB.

Object download. The second half moves bytes, in parallel streams governed by lfs.concurrenttransfers, which defaults to 8. This half scales with total object volume and with the throughput between the runner and the LFS server.

The scoping rule underneath both is where the minutes come from. git lfs fetch works per ref. It downloads the objects the checked-out ref points at, so a one-line shader change pulls the same object set as a branch that rewrote every asset in the tree. The lfs input on actions/checkout is documented as "Whether to download Git-LFS files" and defaults to false, so a workflow only pays this once someone sets it to true, usually because one job needed one binary fixture.

Two multipliers sit on top. Fan-out repeats the whole transfer per job, so a run with a lint job, a type-check job, and a six-way test shard pays it eight times for one commit. And git lfs fetch --all widens the scope from the tip to every object in history, which is the version of this problem that shows up as a job timeout rather than a slow step.

The download also meters against the repository owner. GitHub counts every LFS download against the owner's bandwidth allowance, documented as 10 GiB of bandwidth and 10 GiB of storage on Free, Pro, and Free for organizations, and 250 GiB of each on Team and Enterprise Cloud (GitHub storage and bandwidth docs, checked on 2026-08-13). Fan-out spends that allowance at the same rate it spends runner minutes.

Measure four numbers on your own repository before changing a workflow line.

QuestionHow to get itWhat it decides
Total LFS bytes at the tipgit lfs ls-files -s and sum the size columnThe download half of the cost
Count of tracked pathsgit lfs ls-files and count the linesThe pointer resolution half
Paths the job actually readsgit lfs ls-files -n cross-checked against the build inputsThe include list in the fix below
Time inside the LFS stepStep duration in the job log, median of ten runsThe baseline the model gets compared to

Fix

1. Stop the smudge filter from downloading during checkout. Leave lfs at its default of false, or set GIT_LFS_SKIP_SMUDGE=1 for the job when something else in the pipeline turns it on. The working tree lands as pointer files and checkout finishes at plain-checkout speed.

2. Pull the subset the job reads. git lfs pull --include="<paths>" --exclude="" fetches and checks out only the matching objects. The --include and --exclude options set lfs.fetchinclude and lfs.fetchexclude for that invocation, and patterns match with gitignore wildcard rules. Passing an empty --exclude clears any exclude list inherited from config, so the include list is the whole filter.

3. Give each job its own include list. A test shard that reads audio fixtures and a build job that reads textures want different subsets. Scoping per job is where the arithmetic in the cost model comes from, because the union of the subsets is usually a small share of the tip.

4. Raise the transfer concurrency when the objects are many and small. git config lfs.concurrenttransfers 16 doubles the default of 8. This helps a subset made of thousands of small objects, where request round trips dominate, and does little for a subset made of a few large ones.

5. Cache the object directory between runs. .git/lfs/objects is content addressed, so a run that changes no assets can restore instead of download. WarpBuilds/cache@v1 is a drop-in replacement for actions/cache@v4 and is enabled by default on WarpBuild runners; the restore and save mechanics are in the caching documentation.

6. Move to machine state when the subset itself stops fitting a transfer. Once the objects a job needs run to tens of gigabytes, the restore stops paying and the working set belongs on the disk the job boots with. Snapshot runners boot a later job from a captured VM image with the objects already in place, and the disk shape per label is on the cloud runners catalog and the local SSD runner page.

Configuration

The workflow below checks out pointers, restores the object directory, pulls two asset trees, and saves on the default branch.

name: ci

on:
  pull_request:
  push:
    branches: [main]

env:
  GIT_LFS_SKIP_SMUDGE: 1

jobs:
  test:
    runs-on: warp-ubuntu-latest-x64-8x
    steps:
      - uses: actions/checkout@v4
        with:
          lfs: false
          fetch-depth: 1

      - name: Hash the LFS object IDs for this ref
        id: lfs
        run: |
          git lfs ls-files -l | cut -d' ' -f1 | sort > .lfs-oids
          echo "key=$(sha256sum .lfs-oids | cut -c1-16)" >> "$GITHUB_OUTPUT"

      - name: Restore LFS objects
        uses: WarpBuilds/cache/restore@v1
        with:
          path: .git/lfs/objects
          key: lfs-${{ runner.os }}-${{ steps.lfs.outputs.key }}
          restore-keys: |
            lfs-${{ runner.os }}-

      - name: Fetch only the objects this job reads
        run: |
          git config lfs.concurrenttransfers 16
          git lfs pull --include="assets/textures/**,assets/audio/**" --exclude=""

      - run: ./run-tests.sh

      - name: Save LFS objects
        if: success() && github.ref == 'refs/heads/main'
        uses: WarpBuilds/cache/save@v1
        with:
          path: .git/lfs/objects
          key: lfs-${{ runner.os }}-${{ steps.lfs.outputs.key }}

Three details carry the design. The cache key hashes the object IDs the ref points at, so an asset change misses the key and restore-keys falls back to the newest prefix match, leaving git lfs pull to fetch the delta. The save is gated on success and on the default branch, so a failed run never seeds later ones. And --exclude="" keeps a repository-level exclude list from silently widening or narrowing what the job pulls.

Where every job wants the same subset, move the pattern into a .lfsconfig file at the repository root and drop the flags:

[lfs]
  fetchinclude = assets/textures/**,assets/audio/**
  fetchexclude = archive/**,docs/media/**
  concurrenttransfers = 16

One distinction is worth keeping straight. The filter input on actions/checkout performs a partial clone and controls which git blobs arrive with the history. LFS objects live outside the packfiles, so filter: blob:none shrinks the git side of the transfer while leaving the LFS side untouched. Repositories that carry both a deep history and a large object store need both levers, and the git side is covered in speeding up git checkout in GitHub Actions.

Platform matters for asset pipelines that build on more than Linux. The same include list can serve a Linux test shard and a macOS packaging job under one workflow.

Cost or Time Model

Model inputs, all replaceable with numbers from your own run history:

  • 62 GB of LFS objects at the tip, across 41,000 tracked paths.
  • A test job that reads 4.5 GB of them, the textures and audio trees.
  • 220 MB per second of sustained LFS transfer on the runner.
  • 1.2 minutes for the git fetch and tree write, identical in both columns.
  • 6.0 minutes of build and test after checkout.
  • 8 jobs per run, 900 runs per month, so 7,200 jobs per month.
  • warp-ubuntu-latest-x64-8x at $0.016 per minute, from the pricing page, checked on 2026-08-13.
Linelfs: true on every jobSelective pull
LFS bytes per job62.0 GB4.5 GB
LFS transfer time4.70 min0.34 min
Git fetch and tree write1.20 min1.20 min
Build and test6.00 min6.00 min
Job wall clock11.90 min7.54 min
Cost per job$0.19$0.12
7,200 jobs per month$1,370.88$868.61
LFS bandwidth per month446.4 TB32.4 TB

The runner bill moves by $502.27 per month in this model. The bandwidth line moves further, from 446.4 TB to 32.4 TB against an allowance that tops out at 250 GiB before overage on Team and Enterprise Cloud, which is often the reason this work gets scheduled at all.

Disk is the other constraint the numbers expose. Every WarpBuild Linux label carries 150GB SSD, so a 62 GB object set plus history plus build output sits close to that ceiling on a full pull and comfortably inside it on a 4.5 GB subset. Sizes and rates for the labels worth testing:

Runner labelvCPURAMDiskPrice per minute
warp-ubuntu-latest-x64-4x416 GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832 GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x1664 GB150GB SSD$0.032
warp-ubuntu-latest-x64-32x32128 GB150GB SSD$0.064
warp-ubuntu-latest-arm64-8x832 GB150GB SSD$0.012

Rates from the pricing page, checked on 2026-08-13. Moving up a size buys vCPU and RAM while the disk stays at 150GB, so a job bounded by object transfer gains little from a larger label until the include list has already been narrowed.

For finding where the time actually goes, CI observability correlates runner system metrics with GitHub Actions job logs, which separates a job saturating its cores from a job sitting in a network transfer, and the Action Debugger opens a session on the live runner so you can run git lfs ls-files -s in place. If the object store is only one part of a repository that is large in every direction, read the large repository guide next; if the assets belong to a game project, Unity builds on GitHub Actions covers the pipeline around them.

FAQ

Why is Git LFS slow in GitHub Actions?

Because an LFS checkout pays two costs on top of a plain one. Pointer resolution runs the smudge filter across every LFS-tracked path in the tree and resolves those object IDs against the LFS server, which scales with the number of tracked files. Object download then moves the bytes themselves, scoped to every object the checked-out ref points at rather than to the files the pull request changed.

How do I fetch only some Git LFS files in a workflow?

Leave the checkout action's lfs input at its default of false so the tree lands as pointer files, then run git lfs pull --include="<paths>" --exclude="" for the paths the job reads. The include and exclude options set lfs.fetchinclude and lfs.fetchexclude for that one invocation, and path matching follows gitignore wildcard rules.

Does caching the LFS object directory between runs help?

Yes, when the include list is stable and most objects survive from run to run. Cache .git/lfs/objects keyed on a hash of the object IDs that git lfs ls-files reports for the ref, so a run that changes no assets restores instead of downloading. The keyed entry misses whenever an asset changes, and restore-keys falls back to the newest prefix match.

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.