Pinning Go and Rust Toolchains per Repository
A Go service fails in CI with go: go.mod requires go >= 1.23.1 (running go 1.22.5; GOTOOLCHAIN=local), while a teammate's build silently downloaded a different compiler; a Rust crate compiles on one laptop and fails on another with error[E0658]: use of unstable library feature because one developer is on nightly and the other on stable, and cargo clippy produces different warnings everywhere because each machine has a different Clippy version. Go and Rust both ship first-class, in-repository mechanisms for pinning the exact toolchain; most teams use neither fully. This page sets them up so every clone, CI job and image builds with the same compiler and components, as part of toolchain version management.
Unlike language runtimes managed by external tools, these pins are read by the language's own tooling — go and rustup — so they work without any extra installer beyond the base toolchain.
Diagnostic
Show what each ecosystem thinks the project requires and what it actually uses:
#!/usr/bin/env bash
set -euo pipefail
grep -E '^(go|toolchain) ' go.mod || echo "go.mod has no go/toolchain lines"
go version
go env GOTOOLCHAIN
cat rust-toolchain.toml rust-toolchain 2>/dev/null || echo "no rust-toolchain file"
rustup show active-toolchain 2>/dev/null || true
cargo clippy --version 2>/dev/null || true
Expected bad output:
go 1.22
go version go1.22.5 darwin/arm64
local
no rust-toolchain file
nightly-aarch64-apple-darwin (default)
clippy 0.1.83 (5d3c6ee9e3 2024-09-17)
go.mod sets only a minimum language version, GOTOOLCHAIN=local stops Go from fetching a newer compiler, and Rust falls back to whatever the developer set as their global default — here nightly.
Root cause
Go distinguishes two lines in go.mod. go 1.22 declares the minimum language version the module needs; toolchain go1.23.1 declares the exact toolchain to use. Since Go 1.21, the go command reads these and, with GOTOOLCHAIN=auto (the default), downloads and runs the required toolchain when the installed one is older — unless GOTOOLCHAIN=local was set, often in CI images or by corporate policy, in which case it fails instead. Without a toolchain line, every machine uses whatever Go is installed. Rust's rustup selects a toolchain per directory from rust-toolchain.toml; without that file it uses the user's global default, which may be stable, beta or nightly at any version. Clippy and rustfmt versions follow the toolchain, so lint and formatting results differ too. Neither ecosystem is at fault; the pin files simply were not written.
The cost of the missing pins is highest for linters and formatters. A newer Clippy adds lints and changes suggestions every release, and rustfmt occasionally changes formatting; gofmt is stable, but go vet checks evolve with the toolchain. Without pins, a developer who upgrades locally opens a pull request full of unrelated formatting changes, or CI fails with warnings nobody on the team can reproduce. With the toolchain pinned, lint output becomes a property of the repository rather than of whoever ran it, and upgrading the linter is a deliberate change with its own fix-up commit.
Resolution
- Pin the Go toolchain in
go.mod:
#!/usr/bin/env bash
set -euo pipefail
go get [email protected]
go get [email protected]
grep -E '^(go|toolchain) ' go.mod
This writes go 1.23.0 (minimum language version) and toolchain go1.23.1 (exact compiler). Leave GOTOOLCHAIN at its default auto on developer machines, so an older installed Go transparently fetches 1.23.1.
- Pin the Rust toolchain, components and targets in
rust-toolchain.toml:
[toolchain]
channel = "1.81.0"
components = ["rustfmt", "clippy", "rust-src"]
targets = ["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]
profile = "minimal"
rustup installs exactly this toolchain the first time any cargo command runs in the directory, including Clippy and rustfmt at matching versions and the cross-compilation targets the project builds for.
- Use the same pins in CI and Docker. For Go,
actions/setup-goreads the toolchain fromgo.mod; for Rust,rustupinside the job honours the file:
jobs:
go:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- run: go build ./... && go vet ./...
rust:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- run: rustup show active-toolchain
- run: cargo fmt --check && cargo clippy -- -D warnings && cargo test
In Dockerfiles, base images should match the pin (golang:1.23.1-bookworm, rust:1.81.0-bookworm), or copy rust-toolchain.toml in before the first cargo call so rustup installs the pinned version.
- Check the pins in
make doctor:
#!/usr/bin/env bash
set -euo pipefail
want_go=$(awk '$1=="toolchain"{print $2}' go.mod)
have_go=$(go env GOVERSION)
[ "$want_go" = "$have_go" ] && echo "go $have_go ok" || echo "go: want $want_go, have $have_go (check GOTOOLCHAIN)"
want_rs=$(awk -F'"' '/^channel/{print $2}' rust-toolchain.toml)
rustc --version | grep -q "$want_rs" && echo "rust $want_rs ok" || echo "rust: want $want_rs, have $(rustc --version)"
Expected output
$ go version
go version go1.23.1 darwin/arm64
$ rustup show active-toolchain
1.81.0-aarch64-apple-darwin (overridden by '/home/dev/src/shop/rust-toolchain.toml')
$ cargo clippy --version
clippy 0.1.81 (eeb90cda 2024-09-04)
$ ./scripts/doctor-toolchains.sh
go go1.23.1 ok
rust 1.81.0 ok
Every clone reports the same Go and Rust versions, rustup says the choice came from the repository file, and Clippy's version matches the pinned toolchain — so lint results are identical on every machine and in CI.
Developers who prefer nightly for personal experiments keep it as their global default; inside this repository, the pin file takes precedence, and rustup states that explicitly in its output. That removes a whole category of "works on my machine" reports caused by unstable features that only compile on nightly.
Prevention
Update pins through reviewed pull requests. Renovate understands both the
go.modtoolchain directive andrust-toolchain.toml, so upgrades arrive with a CI run proving them.Fail CI on unpinned repositories with a check that
go.modhas atoolchainline andrust-toolchain.tomlexists with a numeric channel, notstable.Avoid
GOTOOLCHAIN=localin developer environments; reserve it for CI images that should fail rather than download, and make sure those images already contain the pinned version.
Platform caveats
Apple Silicon (ARM64): both toolchains publish native
darwin/arm64builds. Add Linux targets torust-toolchain.tomlif you cross-compile for containers, and setGOARCH/GOOSexplicitly for Go cross-builds.
Corporate proxies: automatic Go toolchain downloads fetch from
proxy.golang.orgby default; setGOPROXYto the internal mirror, and configureRUSTUP_DIST_SERVERif rustup downloads are blocked.
Windows:
rust-toolchain.tomland the Go toolchain directive work on native Windows; targets for Linux cross-compilation need a linker, so building in WSL2 or a container is usually simpler.
Rollback
Remove the pins; each machine returns to its installed or default toolchain:
#!/usr/bin/env bash
set -euo pipefail
go mod edit -toolchain=none
git rm -q rust-toolchain.toml
Frequently Asked Questions
What is the difference between the go and toolchain lines in go.mod?
The go line is the minimum language version the module needs. The toolchain line is the exact Go release to build with. With GOTOOLCHAIN=auto, the go command downloads that release if the installed one is older.
Why does CI say GOTOOLCHAIN=local and refuse to build?
The CI environment disables automatic toolchain downloads and its installed Go is older than the pin. Use actions/setup-go with go-version-file: go.mod, or a base image with the pinned version.
Should the Rust channel be stable or a version number?
A version number. stable moves every six weeks, so two machines on "stable" can differ. Pin 1.81.0 and update deliberately.
Do the pins slow down first builds?
Only once per version per machine: Go and rustup download the toolchain the first time and reuse it afterwards. In CI, cache the toolchain directories to avoid repeated downloads.