go mod vendor — Optimization¶
Honest framing first:
go mod vendoris a copy operation. It walks the module graph, materialises the exact files Go needs to compile, and writes them intovendor/. The command itself is rarely the bottleneck — the slow part is whatever fetched the modules into the cache in the first place. What is genuinely worth optimizing is the workflow around vendoring: when you vendor, how CI consumesvendor/, how thevendor/tree interacts with Docker layers, how it lands in PR diffs, and whether you should be vendoring at all for this particular project.Each entry below states the problem, shows a "before" setup, an "after" setup, and the realistic gain. The closing sections cover measurement and the cases where vendoring is the wrong tool.
Optimization 1 — Vendor for hermetic CI builds¶
Problem: Cold-cache CI runners spend tens of seconds (sometimes minutes) on go mod download: TLS handshakes to proxy.golang.org, sumdb lookups, retries against rate-limited origins. Every job pays this tax even though the dependency set has not changed.
Before:
- uses: actions/setup-go@v5
with: { go-version: '1.23' }
- run: go build ./... # pulls 150 modules from the proxy
After:
Withvendor/ checked in, Go reads dependencies straight off disk. No DNS, no proxy, no sumdb. Expected gain: Cold-cache build phase drops from 30–120 seconds to 0 seconds of network. The remaining time is pure compile, which is what you actually want to measure. Equally important: the build no longer fails when the proxy is down.
Optimization 2 — Combine -mod=vendor with Docker build cache¶
Problem: A Dockerfile that runs go mod download inside RUN invalidates its layer every time go.sum changes — and many teams arrange the Dockerfile so that any source change re-downloads modules.
Before:
Any edit to any file busts the cache and re-downloads every module.After:
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
COPY vendor/ ./vendor/
COPY . .
RUN go build -mod=vendor -o /app ./cmd/api
vendor/ layer is cached as long as vendor/ itself does not change, and go build skips downloads entirely. Expected gain: Image rebuilds on source-only changes go from "download phase + compile" to "compile only." Typical savings: 20–60 seconds per image build, multiplied across every developer push.
Optimization 3 — Cache vendor/ and go.sum together in CI¶
Problem: Some teams vendor but still let CI re-extract vendor/ from a clean checkout each job, defeating the speed advantage of vendoring on cache hits.
Before:
- uses: actions/checkout@v4
- run: go mod vendor # regenerate every job
- run: go build -mod=vendor ./...
vendor/ on every CI run defeats the purpose of having it. After:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: vendor
key: vendor-${{ hashFiles('go.sum') }}
- run: test -d vendor || go mod vendor
- run: go build -mod=vendor ./...
go.sum. When dependencies are unchanged, vendor/ is restored from cache and go mod vendor is skipped entirely. Expected gain: CI time on dependency-stable PRs drops to compile-only. Cache hits are nearly instantaneous; cache misses still rebuild deterministically.
Optimization 4 — Layer ordering for multi-stage Dockerfiles¶
Problem: When vendor/ is copied alongside source code in one big COPY . ., Docker invalidates the cached vendor layer on every source edit. The image rebuild re-tars and re-hashes hundreds of MB of third-party code that never changed.
Before:
Editingcmd/api/main.go invalidates the layer that contains both the code and vendor/. After:
FROM golang:1.23 AS build
WORKDIR /src
# Rare changes — cached aggressively
COPY go.mod go.sum ./
COPY vendor/ ./vendor/
# Frequent changes — only the small layer is rebuilt
COPY cmd/ ./cmd/
COPY internal/ ./internal/
COPY pkg/ ./pkg/
RUN go build -mod=vendor -o /app ./cmd/api
vendor/ layer is reused across thousands of source-only builds. Expected gain: On a vendor of ~80 MB across ~150 modules, layer reuse saves 5–15 seconds per build (filesystem copy + hash) plus push/pull bandwidth on every CI tag. The compounding effect across dev loops is substantial.
Optimization 5 — Skip vendoring on cloud-native CI with a fast proxy¶
Problem: Vendoring is not free. It bloats git clone, slows code review, doubles diff churn on dependency bumps, and forces your team to learn one more workflow. On modern CI with module caching and a healthy GOPROXY, the speed argument for vendoring largely evaporates.
Before:
You pay the vendoring tax even though your CI already cachesGOMODCACHE and your proxy resolves in milliseconds. After:
- uses: actions/setup-go@v5
with:
go-version: '1.23'
cache: true
cache-dependency-path: go.sum
- run: go build ./...
vendor/ entirely. Rely on setup-go's built-in cache plus the proxy. When to vendor anyway: air-gapped builds, regulated industries that audit shipped bytes, or genuinely flaky network paths to the proxy.
Expected gain (when you skip vendoring): smaller repo, smaller PR diffs, no merge conflicts in vendor/, faster git clone, and one fewer concept for new contributors to learn.
Optimization 6 — Prove offline determinism with GOPROXY=off¶
Problem: Teams vendor "for hermetic builds" but never actually verify that the build is hermetic. A stray import can sneak in and quietly fall back to the proxy, defeating the guarantee.
Before:
Ifvendor/ is incomplete, Go falls back to fetching from the proxy and the build silently succeeds — masking a vendoring bug. After:
WithGOPROXY=off, any missing dependency in vendor/ produces an immediate, loud error. CI then catches incomplete vendoring before it reaches production. Expected gain: A truly hermetic guarantee. The "vendor for offline determinism" promise becomes verifiable rather than aspirational. Bug reports of the form "the build fails on the air-gapped runner" go away.
Optimization 7 — Prune unused-but-listed direct dependencies¶
Problem: vendor/ size is largely a function of the module graph, but a surprising amount comes from direct imports that you no longer use. They linger in go.mod, drag in their transitive tree, and inflate the vendor tree.
Before:
go.mod → require github.com/old/lib v1.4.2 // unused since refactor
vendor/github.com/old/lib/... // 12 MB of dead code
go.mod. After:
go mod tidy # drops unused requires
go mod why github.com/some/lib # justify each require
go mod vendor # rewrites a smaller tree
go list -m all | wc -l before and after. Expected gain: On many repos go mod tidy followed by go mod vendor shrinks vendor/ by 10–30%, and the Docker image and clone times shrink with it. The PR diff also becomes more honest about what the project actually depends on.
Optimization 8 — Sub-module-aware vendoring in monorepos¶
Problem: A single vendor/ at the root of a monorepo unions every dependency across every service. One service that needs a heavy AWS SDK forces the whole repo to ship it.
Before:
mono/
go.mod // module github.com/acme/all
vendor/ // 250 MB, includes AWS SDK, GCP SDK, etc.
cmd/api/
cmd/cron/
cmd/cli/ // tiny tool, doesn't need cloud SDKs
After (per-service modules, per-service vendor):
mono/
api/
go.mod
vendor/ // only what api uses
cron/
go.mod
vendor/
cli/
go.mod // no vendor needed, tiny dep set
Expected gain: Smaller per-service Docker images, fewer false-positive CVE alerts (tools scan only what each service ships), and faster per-service CI. Caveat: only worth it once the monorepo is genuinely large; for small repos the operational cost outweighs the savings.
Optimization 9 — Don't re-vendor on every PR¶
Problem: A make vendor step in CI that always runs produces a noisy diff on PRs that touch only application code. Reviewers tune it out, real vendor changes hide in the noise, and the PR turns red for the wrong reason.
Before: Every PR runs go mod vendor and trips a "files changed" check, even when only cmd/api/handler.go was edited.
After (run vendor only when needed):
- name: Detect dependency change
id: deps
run: |
git diff --name-only origin/main... | \
grep -E '^(go\.mod|go\.sum)$' && echo "changed=true" >> $GITHUB_OUTPUT || true
- name: Re-vendor
if: steps.deps.outputs.changed == 'true'
run: go mod vendor
- name: Verify clean
if: steps.deps.outputs.changed == 'true'
run: git diff --exit-code -- vendor/
go.mod / go.sum actually changed. Expected gain: Faster CI on most PRs (skips the vendor step entirely), cleaner reviews, and a reliable signal when vendor should have been updated but was not.
Optimization 10 — Verify vendor consistency in parallel with the build¶
Problem: Verifying vendor (go mod vendor then checking for diffs) is sequenced before the build, padding the critical path even though the two steps are independent.
Before:
jobs:
build:
steps:
- run: go mod vendor
- run: git diff --exit-code -- vendor/ # 5–10s
- run: go build -mod=vendor ./... # 60s
After (matrix):
jobs:
build:
steps:
- run: go build -mod=vendor ./...
verify-vendor:
steps:
- run: go mod vendor
- run: git diff --exit-code -- vendor/
Expected gain: Wall-clock CI time drops to max(build, verify) instead of build + verify. Typically saves 5–15 seconds per pipeline; more on busy queues where queueing is the bottleneck.
Optimization 11 — Separate vendor commits from code commits¶
Problem: A PR that bumps a dependency and changes code lands as one giant diff. The vendor noise (thousands of files) drowns the actual change. Reviewers cannot separate "did the dependency upgrade introduce a regression" from "did the code change make sense."
Before: One commit titled "upgrade lib + refactor handler" with 3,000 changed files in vendor/ plus 30 lines in internal/api/.
After:
# Commit 1: dependency bump + vendor regeneration only
go get github.com/some/lib@v2.0.0
go mod tidy
go mod vendor
git add go.mod go.sum vendor/
git commit -m "deps: bump some/lib to v2.0.0"
# Commit 2: code changes that consume the new API
git add internal/api/
git commit -m "api: switch to lib v2 API surface"
git diff -- ':!vendor') and focus on real code in the second. Expected gain: Faster, more accurate code review. Bisecting later is also dramatically easier — you can revert a dependency bump independently of the code that consumes it.
Optimization 12 — Surface vendor-only diffs cleanly with git tooling¶
Problem: Reviewers and authors lose minutes scrolling past vendor diffs to find the real change. CI logs that git diff everything are unreadable.
Before: git diff main returns 50,000 lines, 49,500 of them in vendor/.
After (review patterns):
# What changed outside vendor/?
git diff main -- ':!vendor'
# What changed *only* inside vendor/?
git diff --stat main -- vendor/
# Show vendor changes summarised, real changes in full
git diff main -- ':!vendor'
git diff --stat main -- vendor/
.gitattributes to mark vendor/ as generated so platforms like GitHub collapse it by default: Expected gain: Faster reviews, less reviewer fatigue, and fewer PRs that get "approved" without anyone actually looking at the substantive diff.
Optimization 13 — For library projects, prefer NOT vendoring¶
Problem: Library authors sometimes commit vendor/ "to help consumers." Consumers do not benefit — Go ignores a library's vendor/ when building consuming code. Meanwhile, every consumer pays a clone-size and review-noise tax.
Before (library go.mod + committed vendor/):
go get github.com/acme/mylib does not use this vendor/ at all; it just makes the source tarball larger. After (no vendor in libraries):
Consumers fetch normally; their own build environment decides whether to vendor.When library vendoring is justified: essentially never. Reproducible test runs in the library's own CI can use go mod download plus a cache; no need to commit the tree.
Expected gain: Smaller library distribution, faster go get for consumers, simpler library CI, and one fewer thing to keep in sync.
Optimization 14 — Re-vendor on a schedule to pick up CVE fixes¶
Problem: A repo that vendored once and never re-vendored is frozen in time. Patch-level CVE fixes in transitive dependencies do not arrive until somebody manually runs go mod tidy && go mod vendor. That "somebody" is usually nobody.
Before: vendor/ last regenerated 14 months ago; CVE scanner flags 6 transitive vulnerabilities that have patched releases available.
After (scheduled refresh):
# .github/workflows/refresh-vendor.yml
on:
schedule:
- cron: '0 6 1 * *' # 06:00 UTC, first of each month
workflow_dispatch:
jobs:
refresh:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: '1.23' }
- run: |
go get -u=patch ./...
go mod tidy
go mod vendor
- uses: peter-evans/create-pull-request@v6
with:
title: 'deps: monthly patch refresh'
branch: deps/monthly-refresh
vendor/ tree. Expected gain: CVE exposure window drops from "whenever someone notices" to roughly 30 days. The PR is small (patch-level only), reviewable, and reversible.
Benchmarking and Measurement¶
Optimization without measurement is folklore. For vendoring workflows the most useful signals are:
# How long does generating vendor actually take?
time go mod vendor
# How big is the resulting tree?
du -sh vendor/
find vendor -type f | wc -l
# Verify hermetic / offline-only behaviour:
GOPROXY=off go build -mod=vendor ./...
GOPROXY=off go test -mod=vendor ./...
# How long does a vendored cold build take vs a non-vendored cold build?
go clean -cache -modcache
time go build -mod=vendor ./...
# Compare against the proxy path
go clean -cache -modcache
time go build ./...
# CI level: track total job wall-clock and Docker layer cache hit rate
# over weeks. A "vendor optimization" that does not move those numbers
# is not an optimization.
Track these numbers before and after each change. Pay particular attention to two metrics: cold-cache CI build time (the headline gain from vendoring) and PR review latency on dependency bumps (the headline cost).
When NOT to Vendor¶
Vendoring is a tool with real costs. It is the wrong default for many projects.
- Small or solo projects: the operational overhead — bigger clones, noisier diffs, an extra workflow to learn — outweighs the modest CI speedup. Use the module cache.
- Library projects: consumers ignore your
vendor/. Committing it just bloats your distribution. - Cloud-native CI with a healthy proxy:
actions/setup-gowithcache: trueplus a fastGOPROXYalready gives you most of the speed benefit without the diff churn. - Teams that will not maintain it: a stale
vendor/is worse than no vendor — it freezes you on outdated transitive versions while giving the appearance of determinism. - Projects on a slow connection but no air-gap requirement: an internal Athens / JFrog Go proxy mirror solves the same problem without committing megabytes of third-party code.
Vendor when you have a concrete reason: air-gapped builds, regulatory audit of shipped bytes, byte-for-byte reproducibility against a snapshot, or a CI environment where the proxy is genuinely unreliable. Otherwise, lean on the module cache, the proxy, and CI caching — and spend the effort you would have spent maintaining vendor/ on something else.
Summary¶
go mod vendor is not slow; the workflows around it are. The wins come from treating vendor/ as a cache strategy, not a habit: cache it in CI, layer it correctly in Docker, regenerate it only when dependencies actually change, separate vendor commits from code commits, prove hermeticity with GOPROXY=off, and refresh on a schedule for CVE hygiene. The biggest optimization, though, is upstream of all of these: deciding honestly whether your project needs to vendor at all. For most modern Go projects with a working proxy and CI cache, the answer is no — and the best optimization is to not vendor in the first place.