Learn · Learning Bazel
seedling
A Library from Outside
The first thing your build needs that you did not write — and the two lockfiles that appear the moment it arrives.
By the end you will have added a third-party Go module, watched the generator wire it into the graph without help, and be able to explain what each of the two lockfiles in your repository is for.
Make the greeting better
Title-casing a name is the kind of thing you should not write yourself. Unicode has opinions about it, and a library already has them:
server/greeting/greeting.go
package greeting
import (
_ "embed"
"fmt"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
//go:embed name.txt
var name string
// Greet builds the message the server prints, title-casing the name
// with a third-party library rather than by hand.
func Greet() string {
caser := cases.Title(language.English)
return fmt.Sprintf("Hello %s", caser.String(name))
}The greeting now runs the embedded name through a proper title-caser rather than doing it by hand.
Declare the module the ordinary Go way:
go.mod
module example.com/greet
go 1.26.5
require golang.org/x/text v0.30.0The module file gains a requirement. This is the file Go programmers already know.
Then tell the build about it:
MODULE.bazel
module(name = "greet")
# Go.
bazel_dep(name = "rules_go", version = "0.61.1")
go_sdk = use_extension("@rules_go//go:extensions.bzl", "go_sdk")
go_sdk.download(version = "1.26.5")
# TypeScript. rules_ts needs a compiler version, which is the chapter's
# point in miniature: the compiler is an input too.
bazel_dep(name = "aspect_rules_js", version = "3.2.2")
bazel_dep(name = "aspect_rules_ts", version = "3.8.11")
bazel_dep(name = "rules_nodejs", version = "6.7.4")
node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node")
node.toolchain(node_version = "22.22.2")
rules_ts_ext = use_extension("@aspect_rules_ts//ts:extensions.bzl", "ext")
rules_ts_ext.deps(ts_version = "5.9.3")
use_repo(rules_ts_ext, "npm_typescript")
# Build-file generation for Go.
bazel_dep(name = "gazelle", version = "0.51.3")
# Third-party Go modules come from go.mod. The extension reads it and
# creates one repository per module; use_repo names the ones the build
# actually depends on.
go_deps = use_extension("@gazelle//:extensions.bzl", "go_deps")
go_deps.from_file(go_mod = "//:go.mod")
use_repo(go_deps, "org_golang_x_text")Three new lines at the bottom. The extension reads go.mod; use_repo names the repositories the build will actually reference.
Let the generator do the wiring
You do not write the deps edge yourself. The generator reads the import and resolves it:
$ bazel run //:gazelleRunning the generator after adding an import. It resolves golang.org/x/text/cases to a label in an external repository.
server/greeting/BUILD.bazel
load("@rules_go//go:def.bzl", "go_library")
go_library(
name = "greeting",
srcs = ["greeting.go"],
# go:embed reads this file, so the build has to be told about it.
# Without this line the compiler cannot see it, even though it sits
# right next to greeting.go on disk.
embedsrcs = ["name.txt"],
importpath = "example.com/greet/server/greeting",
# //cmd/hello needs this too, so the package list grows to name it.
# Widening to //visibility:public would work and would also stop the
# build system from ever asking this question again.
visibility = [
"//cmd:__subpackages__",
"//server:__subpackages__",
],
deps = [
"@org_golang_x_text//cases",
"@org_golang_x_text//language",
],
)The generated result. Two new deps entries, both pointing at an external repository the build fetched.
That @ prefix is the same one from chapter three: a label in a workspace your build fetched rather than one you wrote. The third-party code is a node in the graph like any other — no special case, no separate mechanism.
Prove it ran
$ bazel run //server
Hello World
$ bazel run //cmd/hello
Hello WorldThe output changed. Not "the build succeeded" — the greeting is title-cased now, which only happens if the library actually executed.
This is worth insisting on. A dependency that builds but changes nothing proves only that resolution worked. A changed output proves the code ran.
The two lockfiles
Your repository now has two files nobody types by hand, and they do different jobs. Confusing them is common, so:
go.sum holds cryptographic hashes of the module contents. It answers: is this the same code the author published? It is Go's file, it predates any of this, and it protects you from a registry serving different bytes than it served last week.
MODULE.bazel.lock records how the build resolved everything: which version of each dependency was selected, and what the extensions produced. It answers a different question: given the same declarations, will this build see exactly the same world it saw last time?
Both files exist for the same reason the sandbox exists. An input has to be pinned to be trustworthy, and code you did not write is still an input. The lockfile is that pin, made about the parts of the build you do not control.
Commit both. They are not build artifacts to be regenerated at will — they are the record of what your build actually consists of, and a dependency bump should arrive as a visible change to them rather than as a silent re-resolution on somebody's machine.
Why resolution needs recording at all
It is reasonable to ask why the second file exists. You wrote a version in go.mod. What is left to resolve?
The answer is that you did not write a version — you wrote a constraint, and so did every library you depend on, and so did every library they depend on. Your direct dependency asks for one version of something; a transitive one asks for another. Somebody has to pick, and the picking is a computation over the whole graph.
That computation is deterministic given identical inputs. But its inputs include which versions exist in a registry, and registries gain versions. Run resolution today and again next month with the same declarations and you can get a different answer — not because anything in your repository changed, but because the world did.
A lockfile is the resolution result, written down. Without it, "the same declarations" does not mean "the same dependencies" — it means "whatever the world offered at the moment somebody built." The lockfile is what converts a constraint into a fact.
This is the same move the book has made three times now. The compiler was ambient until chapter two pinned it. The undeclared file was invisible until chapter five declared it. Dependency versions are negotiable until a lockfile records what was negotiated. Each time the pattern is identical: something the build was reading implicitly gets written down, and only then can two machines be expected to agree.
This is also why a lockfile that churns constantly is telling you something rather than annoying you. Every churn is a dependency that moved. If that surprises you, the surprise is the useful part.
Chapter fifteen adds a build flag that makes lockfile drift an error rather than an update. On a build machine that is the setting you want: it turns "someone bumped a dependency without saying so" from a mystery into a failure.
Watch the pin hold
All of that is easier to believe after seeing it refuse something.
Change the required version in go.mod and leave the lockfiles alone — the shape of a hurried dependency bump, or of a merge that took one side of a conflict:
$ bazel build //server
ERROR: No sum for golang.org/x/text@v0.29.0 found, update go.sum with:
ERROR: //server/greeting:greeting depends on
@@gazelle++go_deps+org_golang_x_text//language
in repository ... which failed to fetch.The manifest now asks for a version nothing has recorded a hash for.
The build will not fetch a version it has no record of. Not a warning and not a prompt — it stops, names the module and version, and tells you what to update.
The native tooling agrees, and it is worth seeing that it does:
$ go build ./server/...
missing go.sum entry for module providing package
golang.org/x/text/cases; to add:
go get example.com/greet/server/greetingModern Go refuses too. This is the good outcome, and it is the default.
So this is not a case where one tool is careful and the other is not. It is a case where both are careful by default, and where the default can be turned off:
$ GOFLAGS=-mod=mod go build ./server/...
$ echo $?
0One environment variable, and the same edit succeeds — because the tool fetched the module, computed a hash, and wrote it into your lockfile for you.
That is how a dependency change arrives in a repository without anybody deciding to make it. Not through carelessness — through a convenience flag somebody set once, in a shell profile or a continuous-integration script, and nobody revisited.
A lockfile only pins anything if something refuses to proceed when the pin does not match. A file that gets rewritten to agree with whatever just happened is a log, not a lock.
That is worth knowing before chapter fifteen, which turns the same refusal on for build machines with a single flag. On your own laptop an automatic update is usually what you want. On a machine producing artifacts other people will run, it is exactly what you do not.
Which is why "the versions are pinned" is not a property of a file. It is a property of a file plus the mode every tool that touches it runs in — and the second half is the one nobody writes down.
What this costs
Being honest about the trade: you now have a second place versions live, and a class of confusion where go.mod and the build disagree until you rerun something.
That cost is real and it is the price of the guarantee. Without it, the version you get depends on when you built and what your machine had cached — which is exactly the ambiguity chapter one's Makefile had about name.txt, moved up a level to your entire dependency tree.
A recipe that says "flour" works until two bags of flour turn out to be different. Writing down which bag — brand, mill, batch — is annoying right up to the first time someone else makes your cake and it comes out wrong.
Third-party Go modules are declared in go.mod and made visible to the build by an extension that reads it. The generator resolves imports to external labels without help. Two lockfiles result and they answer different questions: go.sum asks whether the bytes are authentic, the build's lockfile asks whether resolution is reproducible. Commit both.
Try this in your own repository
Find out which mode your tools are in. For every dependency manager you use, determine whether an unrecorded version is an error or an automatic update — and check the environment as well as the config, since a variable in a CI script can flip it. "The versions are pinned" is a claim about the mode, not the file.
Try to rebuild an old release exactly. Check out a tag from six months ago and build it. Whatever you have to fix by hand is a thing your lockfiles were not pinning.
What you can now do
Add a third-party Go module to a build and explain what each lockfile guarantees. The next chapter does the same for the other language, where nothing is quite this easy.