Skip to content
intermediate Phase 7 · Packages, Modules, and Project Layout

Multi-Module Workspaces with go.work

Build several local modules together without publishing or using replace directives.

30m
0 problems
Topic Progress 0%

The replace Directive You Keep Forgetting to Delete

One repository, two modules: pkg/redisx is a connection-pool wrapper published for other teams, and cmd/server consumes it like any external library. You find a bug in redisx, fix it, and need the server to see the fix. Before modules had workspaces, one line did it:

// cmd/server/go.mod — the fix that should never be committed
require github.com/acme/redisx v1.3.0

replace github.com/acme/redisx => ../../pkg/redisx

It works on your laptop. It also works on nobody else's laptop, because their checkout lives at a different path. It breaks CI, which builds from a tarball without ../../. If it slips into a commit, every downstream consumer of cmd/server inherits a dependency on your hard-coded directory — and the published v1.3.0 they actually get may not contain your fix at all. The replace was meant to be temporary local glue, and temporary local glue that lives in committed source is a trap.

go work exists for exactly this: local overrides that never enter a go.mod.

go work init .
go work use ./cmd/server
go work use ./pkg/redisx

Which writes:

go 1.22

use (
	./cmd/server
	./pkg/redisx
)

Inside this workspace, any import of github.com/acme/redisx resolves to the directory on disk — regardless of which version cmd/server requires. Edit redisx, rebuild the server, see the change. The server's own go.mod still says require ... v1.3.0; nothing about the published dependency graph changed. That separation is the whole design: go.mod describes what the module needs from the world, go.work describes what your machine substitutes while you develop.

A workspace file sits at a directory and applies to everything below it. It has no require block of its own — it does not pick dependency versions — and normally never sees a commit: add go.work and go.work.sum to .gitignore, because each developer works on a different subset of modules than the one who created the file.

What Actually Changes When a Workspace Is Active

A workspace is not a build mode you opt into per command — it is ambient state the toolchain reads from the directory tree. go env GOWORK prints the active file (or off), and every command below that directory inherits it. Four behaviors are worth knowing precisely.

Local modules shadow published versions. A use directive makes that module's packages resolve from disk for every module in the workspace. If the server requires redisx v1.3.0 and your checkout has the fix, the build uses your checkout. No go.mod is edited.

Commands operate across the workspace. Package patterns expand over all used modules, so the root command you actually wanted already exists:

go build ./...   # builds every package in every used module
go test ./...    # tests them all, one cache, one binary set
go vet ./...

No shell loop over subdirectories, no per-module CI matrix locally.

go work sync propagates selections. When a workspace combines module graphs, dependency versions selected while building together can differ from what each module requires alone. go work sync writes the workspace-selected versions back into each module's go.mod, so the modules agree when they are built independently (which is how CI builds them). Run it after dependency changes, then commit the individual go.mod files — sync edits modules, not the workspace.

GOWORK=off removes the overlay. This is the command that turns "works on my machine" into a reproducible check:

GOWORK=off go build ./...   # build exactly as CI does — each module on its own

When CI fails but your workspace build passes, the usual story is a go.mod that was never synced or a package that only compiles because a sibling module shadowed a published one. GOWORK=off reproduces it in seconds; go work sync plus a commit usually fixes it.

One question teams argue about: commit go.work or ignore it? Ignore is the safe default — a committed workspace silently changes builds for everyone who clones, and developers who open a subdirectory get different tooling behavior than the author intended. Commit it only if the whole team works the full set of modules and CI builds the workspace too, which is a commitment to a layout, not a file.

One Module or Six? Decide Before You Reach for go.work

A workspace makes multi-module repos pleasant to edit, but it does not answer whether you should have more than one module. That decision comes first, and it has three real answers.

One module (the default). One go.mod for the repository: one version line, one set of tags, one build list. Choose it when everything in the repo releases together and consumers install the whole thing. Monoliths of packages, services deployed independently — still one module unless something forces the split.

Multiple modules. Justify the split only when one of these is true: the repo holds things with different release cadences (a CLI tool and a library it uses, versioned apart); the library must keep a lean require list rather than dragging every service dependency into consumers' version selection; or teams own separate dependency graphs. The costs are real: every submodule needs path-prefixed tags (pkg/redisx/v1.4.0), cross-module changes span two go.mod files, and CI must build modules individually — precisely the mode GOWORK=off reproduces.

The workspace is the editor layer. Whatever you choose, go.work solves only the local experience of a multi-module repo. It does not publish anything, does not merge go.mod files, does not version your code, and does not exist in CI unless you create it there. Deleting go.work must never change what a released artifact contains — if it does, a replace or a version pin leaked into committed state.

Where does the old replace still belong? Legitimate uses are narrow: pointing at a patched fork while an upstream fix is pending (documented, dated, removed when merged), or a hermetic build arrangement you deliberately ship. Local cross-module editing is no longer one of them — that is what go work use is for.

A compact decision table:

Repo looks like Structure
One product, one release train single module
Library + tool with independent versions two modules, go.work for editing
Forked dependency awaiting an upstream fix replace, temporary and commented
Cross-module change spanning two go.mods go work use locally, go work sync before committing

Whichever structure you land on, the code inside still has to obey package-level rules — nothing in a workspace excuses a package that imports itself sideways. The moment the compiler says import cycle not allowed, the fix lives in Import Cycles and Package Design, and the day-to-day import hygiene lives in Imports, goimports, and go mod tidy.

Quiz

1. In an active workspace, cmd/server requires github.com/acme/redisx v1.3.0, and `go work use ./pkg/redisx` points at a local checkout with unreleased changes. Which redisx code does `go build ./cmd/server` compile?

Question 1 options

2. Your workspace build is green but CI, which builds each module independently, fails. What is the most likely cause and fix?

Question 2 options

3. What is the essential difference between a `replace` in go.mod and a `use` in go.work?

Question 3 options

4. Your repo holds a library at ./pkg/redisx (module github.com/acme/repo/pkg/redisx) and you cut release v1.4.0. Which git tag makes `go get` find it?

Question 4 options

Flashcards

Question

What problem does `go work use` solve that a `replace` directive solves badly?

Answer

It makes local directories stand in for published modules without editing any go.mod. replace is committed, path-specific, and inherited by every consumer; use lives in a per-developer go.work that CI and downstream modules never see.

Question

What does `go work sync` actually write, and where?

Answer

It writes the dependency versions selected during workspace builds back into each used module's go.mod. It never edits go.work (which has no require block) — it aligns the individual modules so they build consistently when CI compiles them outside the workspace.

Question

When should you use `GOWORK=off`?

Answer

Whenever you need the build CI will run: each module compiled alone against its own go.mod, with no local shadowing. It is the fastest way to confirm that a green workspace build is real rather than an artifact of your machine's go.work.

Question

When is a multi-module repository justified over one module?

Answer

When parts release independently (a CLI plus a shared library), when a published library must keep its require list lean instead of inheriting the service's whole dependency graph, or when teams own separate dependency graphs. One release train, one module.

Question

Should go.work be committed to the repository?

Answer

Usually not: it is developer environment state, it changes resolution for anyone who clones it, and developers opening a subset of modules get surprising builds. Commit it only when the whole team and CI deliberately work and build the full workspace.

Revision Notes

Key Takeaways

  • 1. use directives shadow published versions locally without touching go.mod — that is the entire value proposition
  • 2. go.work is ambient environment: go env GOWORK shows it, GOWORK=off turns it off, CI usually runs without it
  • 3. go work sync aligns each module's go.mod with workspace selections so standalone builds agree
  • 4. Multi-module is for independent release cadences and lean published require lists — not for folder tidiness
  • 5. The old replace stays reserved for temporary forks; local editing is no longer a legitimate use

Interview Tips

  • • "How do you develop two modules in one repo?" — go work init/use for local edits, keep each go.mod authoritative, run go work sync before committing dependency changes, and note go.work stays uncommitted
  • • "CI passes locally but fails remotely in a multi-module repo" — walk GOWORK=off first to reproduce, then explain shadowing and unsynced go.mod as the two candidate causes
  • • "replace vs go.work?" — replace ships to every consumer and hard-codes paths; use is per-developer environment; keep replace for documented temporary forks only
  • • "How do you version a submodule?" — path-prefixed tags (pkg/redisx/v1.4.0); mention that workspace files play no role in the release

Cheat Sheet

Multi-Module Workspaces with go.work

Commands:

Task Command
Create a workspace go work init .
Add a local module go work use ./pkg/redisx
Build/test all used modules go build ./... / go test ./...
Record workspace selections in each go.mod go work sync
Build exactly like CI GOWORK=off go build ./...
Show the active workspace go env GOWORK

go.work file:

go 1.22

use (
	./cmd/server
	./pkg/redisx
)

No require block — a workspace overrides local resolution, it does not choose dependency versions. Gitignore go.work and go.work.sum by default.

Rules of thumb:

  • replace = committed, affects all consumers (forks and emergencies only)
  • use = local, per-developer, invisible to CI
  • submodule tags are prefixed: pkg/redisx/v1.4.0
  • workspace green + CI red → GOWORK=off to reproduce, go work sync + commit go.mods to fix
  • deleting go.work must never change a released artifact

Continue Learning

Before You Start

  • No prerequisites listed.

Next Topics

Related Reading