skip to content

Why does a `replace` line in a dependency's go.mod have no effect on your build?

level: middleimportance: should knowfreq 48%

answer

  1. whose go.mod is the go command reading?
  2. only one module in the graph gets a vote
  3. reproducibility, and who may swap your source
  4. a library cannot redirect its importers
  5. one directive travels the other way

basics

~20 s

The go command honours replace and exclude directives only from the main module's go.mod, the one you build from. In a dependency they are read and ignored, so a library cannot force redirects on the modules that import it.

solid answer

~40 s

`replace` and `exclude` are main-module-only by design. The main module is whichever module contains the directory you ran the go command in; every other `go.mod` in the graph contributes its `require` lines and nothing else. The reason is reproducibility and supply-chain safety: if a transitive dependency five levels down could rewrite where your code comes from, no consumer could reason about what they were compiling. The consequence bites library authors. If your module builds only because its own `go.mod` says `replace example.com/lib => example.com/ourfork/lib v1.4.2`, then everyone who imports you gets plain upstream `example.com/lib` and, quite possibly, a compile error you never see. `retract` is the one directive that goes the other way: the go command *does* read retractions from a dependency's `go.mod`, because a retraction is advice the publisher is entitled to give.

code

json · 13 lines
json
{
	"Module": {"Path": "example.com/internal/billing"},
	"Go": "1.25",
	"Require": [
		{"Path": "example.com/upstream/parser", "Version": "v1.4.2"}
	],
	"Replace": [
		{
			"Old": {"Path": "example.com/upstream/parser"},
			"New": {"Path": "example.com/ourorg/parser-fork", "Version": "v1.4.2"}
		}
	]
}

go deeper

for a junior

Remember the one-line rule: only the go.mod you build from supplies replace and exclude. If a redirect is not in your own module's file, it is not in your build.

for a middle

Explain the mechanism and the reason together — the module graph contributes requirements from everywhere but directives only from the main module, for reproducibility and to stop a transitive dependency substituting your source.

for a senior

Demonstrate the library-author failure and its exits: upstream the patch, inline the code, or publish the fork as a real requirable module. Show how you would catch it in review before publishing.

for a principal

Own the standard: whether any module your organisation publishes may depend on a replace at all, and what the release checklist enforces so a green CI run on the library repo does not mislead consumers.

## Which go.mod gets a vote When the go command builds, it constructs a module graph: the main module's requirements, their requirements, and so on. The **main module** is the module containing the directory the command was invoked in. Every `go.mod` in that graph contributes `require` lines. Only the main module's contributes `replace` and `exclude`. That single rule explains a whole family of confusing outcomes: - A dependency's `replace` line has no effect on your build. - A dependency's `exclude` line has no effect on your version resolution. - A `replace` you add locally, that fixes your build perfectly, vanishes for anyone who imports your module. - Running the tests of a dependency *inside its own checkout* works, because there it is the main module — and the same code fails when compiled as your dependency. ## Why the rule exists Two reasons, and both are worth being able to state. **Reproducibility.** A build should be a function of the main module's `go.mod` plus the published contents of the modules it names. If arbitrary modules in the graph could rewrite each other, the resulting build list would depend on the traversal order of a graph nobody reads, and two people could resolve the same requirements differently. **Supply-chain safety.** A `replace` can point an import path at completely different code, including code on a different host or a directory on disk. If a transitive dependency could do that, adding any dependency would mean granting every module beneath it the power to substitute the source of anything in your build. Confining the directive to the main module means the substitution is always a decision someone in your repository made and can be seen in one file. ## The library-author trap This is the practical failure and the reason the question gets asked. An engineer needs a fix that upstream has not released. They fork, add `replace upstream => ourfork v1.4.2`, everything builds, tests pass, CI is green, and the module is published. Consumers then import it and compile against *unpatched upstream*, because their main module never mentioned the fork. Depending on the patch, they get a compile error, or — worse — a silent behaviour difference. The honest fixes, in rough order of preference: 1. Land the change upstream and require the released version. 2. Copy the small amount of code you actually need into your own module and drop the dependency on that part. 3. Publish the fork as a real module under a path you control and `require` it directly — no replace involved. Consumers now see the fork in their own build list, which is exactly the transparency the rule is protecting. 4. As a last resort, document that consumers must add the same `replace` themselves. This is a burden you have pushed onto everyone downstream, and it does not compose: two libraries needing conflicting replaces of the same module cannot both be satisfied. ## The exception that proves the rule `retract` behaves differently on purpose. The go command reads retractions from a module's own published `go.mod` when deciding which of that module's versions to select, no matter where the module sits in the graph. The asymmetry is coherent: `replace` and `exclude` are a *consumer* saying what their build should contain, so only the consumer at the top gets to say it; `retract` is a *publisher* saying which of their own versions are unfit, which is information the consumer wants. ## Seeing what is actually in effect `go mod edit -json` prints the current module's directives in machine-readable form, which is the fastest way to answer "what is this build really resolving with" in a repository with several replace lines and several people editing them. Run it in a dependency's checkout and you will see that dependency's replace block — a useful demonstration that the block exists and that your build is not using it. `go list -m all` shows the resolved build list, printing each replacement as `original => replacement`; a redirect you expected that does not appear there did not fire.

  • You maintain a library that only compiles because of a replace in its own go.mod. What must you do before publishing it?
    Remove the dependency on the replace. Land the patch upstream and require the release, copy the needed code into the library, or publish the fork as a module under a path you control and `require` it directly. Shipping the library with the replace in place means consumers compile against unpatched upstream.
  • Which go.mod directive is read from a dependency's go.mod rather than only the main module's?
    `retract`. The go command loads retractions from a module's own published go.mod when selecting versions of that module, wherever it sits in the graph. That is coherent: replace and exclude are the consumer stating what their build contains, while a retraction is the publisher warning about their own releases.
  • Why did the Go team confine replace to the main module instead of letting it propagate?
    Because propagation would let any transitive dependency substitute the source of any import in your build, and would make the resolved build list depend on graph traversal rather than on one readable file. Confining it means every substitution is a decision visible in the repository you are building.
  • Why do a dependency's tests pass in its own checkout but fail when it is compiled as your dependency?
    Inside its own checkout it is the main module, so its replace and exclude lines apply. Pulled in as your dependency it is not, so those directives are ignored and it resolves against whatever your main module and the graph select. Reproduce it by building from a consuming module, not from the library.

saying these in an interview costs you the question

  • Says a replace propagates to everyone who imports the module
  • Blames the module proxy or cache for the ignored directive
  • Thinks go mod tidy would have applied the dependency's replace
  • Claims exclude propagates even though replace does not
  • Believes retract is main-module-only like the other two