DEV Community

Cover image for go.mod replace Directives: When They Save You and When They Trap You
Gabriel Anhaia
Gabriel Anhaia

Posted on

go.mod replace Directives: When They Save You and When They Trap You


A team I worked with shipped a fix for a panic in an upstream HTTP client. The maintainers were slow to merge. The team forked the repo, patched the bug, and added one line to their service's go.mod:

replace example.com/httpc => example.com/httpc-fork v0.4.1
Enter fullscreen mode Exit fullscreen mode

Tests passed. The deploy went out. Two weeks later a downstream service that imported their library started panicking on the same code path the patch was supposed to have killed.

The fork was correct, the replace was correct, and the downstream service still did not see either of them. That is the part of replace directives nobody explains until it bites.

The three replace patterns that earn their keep

Before the trap, the wins. There are three uses of replace that genuinely improve your life and that are hard to do any other way.

1. Local development across modules

You have two modules in the same repo or organisation. service-api imports shared-pkg. You want to make a change in shared-pkg and see it reflected in service-api without publishing a tag every five minutes.

Add this to service-api/go.mod:

require github.com/yourorg/shared-pkg v1.4.2

replace github.com/yourorg/shared-pkg => ../shared-pkg
Enter fullscreen mode Exit fullscreen mode

go build now reads shared-pkg from the relative path. The version on the require line still matters for tooling that resolves graphs, but for compilation Go uses your local copy.

The fix for the "and now I have a replace line in my committed go.mod" problem is go work. More on that below.

2. Vendor fork

You depend on a library and you have found a bug. The maintainer is unreachable, slow, or wrong. You fork the repo, fix the bug, and point your module at the fork:

replace example.com/httpc => example.com/httpc-fork v0.4.1
Enter fullscreen mode Exit fullscreen mode

The right side can be a different module path (a real fork) or the same path with a different version. Fork paths are clearer because the import path of your code does not change; the consumer of example.com/httpc keeps writing import "example.com/httpc" and the build resolves to the fork.

Tag your fork. Pseudo-versions (v0.0.0-YYYYMMDDHHMMSS-abcdef) work but make your go.mod look like a crime scene six months later when nobody remembers what 9a8f referred to.

3. Security pin while waiting for upstream

A CVE drops in a transitive dependency. The library you import has not bumped its requirement yet. You can wait, or you can pin:

require example.com/parser v1.2.3
replace example.com/parser => example.com/parser v1.2.4
Enter fullscreen mode Exit fullscreen mode

You force the patched version into the build even though no direct dependency has asked for it. This is the same idea as npm overrides or pnpm overrides — a sledgehammer that pulls a specific version into the resolution.

Go has a softer alternative: bump the require line directly to the patched version. That works when you import the package directly. replace is the right choice when the dependency is transitive and editing require would be misleading about what you actually import.

A nicer middle ground for the CVE case is exclude, which marks a specific version as off-limits so MVS skips it. exclude alone does not force a higher minimum; you still need at least one module in the graph to require the patched version for the resolver to land there. replace is heavier; reach for exclude first when a require bump can do the rest.

The trap: replace does not compose

This is the part that sinks teams.

A replace directive in your go.mod only applies when your module is the main module of the build. If another module imports yours, your replace lines are ignored.

The Go modules reference is explicit about this in the replace directive section: "replace directives only apply in the main module's go.mod file and are ignored in other modules."

That means:

  • If you ship a binary, your replace lines work. Good.
  • If you ship a library, your replace lines work for your tests and your CI. They are invisible to every consumer of your library.

Back to the team that forked the HTTP client. Their service was a library. The replace line patched the panic in their own tests. Downstream services pulled the library, resolved the original example.com/httpc from the proxy, and panicked exactly the way the unpatched code was supposed to panic.

The fix for that situation is not to pretend replace works. The fix is to:

  1. Push the fork to a real path your downstream consumers also pull.
  2. Update require (not replace) in your library to point at the fork path.
  3. Document the fork as a transitive requirement so consumers know what they are pulling.

Or push the fix upstream and stop forking. The library author cannot ask consumers to add a replace line; that is not how Go modules work, and it would not survive being a transitive dependency anyway.

CI surprises with uncommitted replaces

The second trap is operational. A developer adds a local replace for a quick test:

replace github.com/yourorg/shared-pkg => ../shared-pkg
Enter fullscreen mode Exit fullscreen mode

They forget to remove it. They commit. CI clones the repo without the sibling directory and the build dies with directory ../shared-pkg does not exist. Or, worse, CI has a sibling directory at that path because of how the build agent is configured, and the build silently picks up a different version of shared-pkg than the rest of the team gets locally.

Two habits keep this from happening:

  • Pre-commit hook that greps go.mod for replace .* => \.\./ and rejects the commit. Local-path replaces never belong in a committed go.mod.
  • Use go work for local cross-module work. The go.work file lives at the repo root and overrides resolution without touching any module's go.mod. Many teams gitignore it so each developer can configure their own checkout, though the Go team explicitly allows committing go.work for monorepos where everyone wants the same workspace shape.

go work for the monorepo case

Here is the go.work shape for two sibling modules:

// go.work
go 1.24

use (
    ./service-api
    ./shared-pkg
)
Enter fullscreen mode Exit fullscreen mode

With this file in place, service-api resolves shared-pkg from ./shared-pkg regardless of what its go.mod says, and you do not need a replace line in service-api/go.mod at all.

go.work also has its own replace directive. It works exactly like the one in go.mod but applies only when this workspace is active. That is the right place to pin a fork while you are iterating on it locally.

Two rules of thumb:

  • Decide deliberately whether to commit go.work. The common pattern in multi-team repos is to gitignore it so each developer chooses what to include; the Go team also supports committing it when a monorepo wants every contributor on the same workspace. What matters is that CI and production should run with whatever shape your team agreed on, not whatever happened to be on a developer's laptop.
  • GOWORK=off is the escape hatch. Run GOWORK=off go test ./... to confirm a module still builds without the workspace overrides. Do that before you push.

A workspace solves the "I want to develop two modules at once" problem cleanly. It does not solve the "I forked a library and I want my consumers to see the fork" problem. Different problem, different tool.

A real go.mod you can paste

Putting it together, here is what a service's go.mod looks like with both legitimate uses of replace:

module github.com/yourorg/service-api

go 1.24

require (
    example.com/httpc v1.4.0
    example.com/parser v1.2.3
    github.com/yourorg/shared-pkg v1.4.2
)

// Forked to patch a panic; PR #4421 upstream still open.
// Remove when upstream merges.
replace example.com/httpc => example.com/httpc-fork v1.4.1

// CVE-YYYY-NNNNN (placeholder) patched in v1.2.4.
// Drop when direct dependencies bump past v1.2.4.
replace example.com/parser => example.com/parser v1.2.4
Enter fullscreen mode Exit fullscreen mode

Two things to notice:

  • Every replace has a comment that says why it exists and when to remove it. A replace line without an expiry condition becomes load-bearing technical debt within a year.
  • There is no replace github.com/yourorg/shared-pkg => ../shared-pkg. That belongs in go.work, not here.

Decision rule

A replace directive in go.mod is right when one of three conditions holds:

  • You are the main module (a binary, a CLI, the leaf of a build), and you want to swap a dependency.
  • You are pinning a security patch ahead of an upstream bump.
  • You are pointing at a fork while a PR is open upstream.

It is wrong, or at best a footgun, when:

  • You are a library and you expect consumers to inherit the replace.
  • The right side is a relative path. Use go.work instead.
  • The line has been there longer than the comment above it suggested it would be.

Read the Go modules reference and the go work documentation once. The two pages together are about ten minutes of reading and they prevent most of the team-scale replace confusion that shows up in production.

The directive is a small lever. It is also one of the few lines in a Go project that can change behaviour for everyone who builds the code. Treat it that way.


If this saved you a debug session

The Go toolchain has a small number of moving parts that, once you understand them, make most of the surprises stop being surprises. Module resolution is one of them. The other big ones are how the runtime schedules goroutines, how the linker handles init, what the compiler chooses to inline. The Complete Guide to Go Programming walks through each of these the way I would explain them to a colleague at a whiteboard. The book gives you enough detail to make the right call when something feels off, without padding that puts the book down for you. The companion volume, Hexagonal Architecture in Go, takes the same care to module boundaries and how to lay out a multi-module repo so the replace and go.work machinery stays out of your way.

If you ship Go alongside an AI coding assistant, Hermes IDE is the editor I build for that workflow. It is built for the loop where the AI reads and edits your Go code with you, not at you.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)