- Book: The Complete Guide to Go Programming
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
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
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
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
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
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
replacelines work. Good. - If you ship a library, your
replacelines 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:
- Push the fork to a real path your downstream consumers also pull.
- Update
require(notreplace) in your library to point at the fork path. - 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
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.modforreplace .* => \.\./and rejects the commit. Local-path replaces never belong in a committedgo.mod. - Use
go workfor local cross-module work. Thego.workfile lives at the repo root and overrides resolution without touching any module'sgo.mod. Many teams gitignore it so each developer can configure their own checkout, though the Go team explicitly allows committinggo.workfor 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
)
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=offis the escape hatch. RunGOWORK=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
Two things to notice:
- Every
replacehas a comment that says why it exists and when to remove it. Areplaceline 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 ingo.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.workinstead. - 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.

Top comments (0)