Before Go 1.18, working across multiple locally-checked-out modules was genuinely painful. You’d edit a library, then edit the go.mod of every dependent module to add a replace directive pointing to your local path, run your tests, commit — and then inevitably forget to revert those replace lines before pushing. CI breaks. Your colleague pulls and wonders why their build is pointing at /home/yourname/projects/mylib. Classic.
Go workspaces (go.work) landed in Go 1.18 and solved this properly. Not a hack, not a convention — a first-class feature of the toolchain. If you’re building a monorepo, a CLI + shared library combo, or any project spanning multiple Go modules, this is the workflow you should be using.
The official toolchain docs live at https://go.dev/ref/mod#workspaces and the original proposal at https://github.com/golang/go/issues/45713.
The Problem: Why replace Directives Are a Trap
Say you have two modules:
~/projects/
myapp/ # go.mod: module github.com/you/myapp
mylib/ # go.mod: module github.com/you/mylib
myapp imports mylib. You’re working on both simultaneously — adding a new function to mylib and consuming it in myapp. Without workspaces, your only option is to jam this into myapp/go.mod:
replace github.com/you/mylib => ../mylib
This is a local path replace. It works. But:
- You have to remember to delete it before committing
- It doesn’t compose — if you have five modules depending on
mylib, you’re editing fivego.modfiles - It bleeds into git history if you slip up even once
- CI/CD systems will refuse to fetch a module with a local
replacepointing nowhere
go.work lifts this entire concern out of go.mod entirely. The workspace file lives at the repo root and is explicitly meant to stay out of CI.
Setting Up a Go Workspace
You need Go 1.18+. Check with go version. If you’re on anything older, upgrade — there’s no reason not to be on at least 1.21 at this point.
Initialize the workspace
Navigate to your repository root (the directory containing both modules, or the root of your monorepo) and run:
go work init ./myapp ./mylib
This creates a go.work file:
go 1.22
use (
./myapp
./mylib
)
That’s it. No more replace directives. The Go toolchain now knows about both modules and will resolve cross-module imports locally when you’re working in workspace mode.
Adding modules later
go work use ./another-service
Or edit go.work directly — it’s just a text file.
What workspace mode actually does
When go.work is present (or when GOWORK env var points to one), the Go toolchain enters workspace mode. In this mode, any module listed under use takes precedence over the module registry. So when myapp does import "github.com/you/mylib/pkg/foo", Go resolves it to your local ./mylib directory, not the published version on pkg.go.dev.
Everything else — go build, go test, go mod tidy — behaves normally. You don’t change how you write code.
A Real-World Example: Monorepo Structure
Let’s build a concrete scenario. You have:
api-server— the main HTTP servicedomain— shared domain types and interfaceseventbus— a thin wrapper around NATS or Redis Streams
repo/
├── go.work
├── api-server/
│ ├── go.mod
│ ├── go.sum
│ └── main.go
├── domain/
│ ├── go.mod
│ └── types.go
└── eventbus/
├── go.mod
└── bus.go
domain/go.mod:
module github.com/you/repo/domain
go 1.22
eventbus/go.mod:
module github.com/you/repo/eventbus
go 1.22
require (
github.com/you/repo/domain v0.0.0
)
api-server/go.mod:
module github.com/you/repo/api-server
go 1.22
require (
github.com/you/repo/domain v0.0.0
github.com/you/repo/eventbus v0.0.0
)
go.work at the repo root:
go 1.22
use (
./api-server
./domain
./eventbus
)
Now api-server can import github.com/you/repo/domain and github.com/you/repo/eventbus, and Go will resolve them from the local directories. The v0.0.0 version in go.mod is fine — it’s never fetched, just used as a placeholder that satisfies the module graph requirement. When you publish, you’ll tag real versions and the v0.0.0 won’t survive.
Running Commands in Workspace Mode
Most go commands just work:
# From repo root — builds api-server, resolving deps from workspace
cd api-server && go build ./...
# Run tests across all modules
go work sync && go test ./...
# Tidy a specific module (run inside that module's directory)
cd domain && go mod tidy
One thing that catches people: go mod tidy operates on a single module, not the workspace. Run it inside each module’s directory separately. There’s no go work tidy that cascades (as of Go 1.22). You have to do it per module.
go.work.sum: The File Nobody Talks About
When you run commands in workspace mode, Go creates a go.work.sum file alongside go.work. This is the checksum database for dependencies introduced transitively by your workspace modules that aren’t yet in any individual module’s go.sum.
Commit go.work.sum to git. Don’t gitignore it. It ensures reproducible builds for anyone else working with the workspace. go.work itself should also be committed if it’s a team repo — the convention of ignoring it only applies if you’re using workspaces as a personal local override.
Gotchas
Gotcha #1: Workspace mode breaks in CI by default
CI doesn’t have your local module directories. If go.work is committed and your CI runs go build, it’ll fail because the workspace-listed paths don’t exist (or point to wrong versions).
The fix: disable workspace mode in CI with the environment variable:
GOWORK=off go build ./...
Or set it in your CI pipeline config:
# GitHub Actions example
- name: Build
env:
GOWORK: "off"
run: go build ./...
With GOWORK=off, Go ignores the workspace entirely and resolves everything from go.mod and the module proxy. Your module versions in go.mod must be real, published versions when GOWORK=off.
Gotcha #2: go mod tidy doesn’t touch the workspace
Running go mod tidy from the workspace root doesn’t cascade. It operates on the module in the current directory. If you add a new import to api-server, tidy api-server. If you add one to eventbus, tidy eventbus. Build a small script if this gets tedious:
#!/usr/bin/env bash
# tidy-all.sh — run go mod tidy for every module in the workspace
set -euo pipefail
for dir in $(go work edit -json | jq -r '.Use[].DiskPath'); do
echo "Tidying $dir"
(cd "$dir" && go mod tidy)
done
Gotcha #3: Version mismatches between modules
If api-server requires github.com/some/dep v1.5.0 and eventbus requires github.com/some/dep v1.3.0, Go’s MVS (Minimum Version Selection) picks v1.5.0 for the entire workspace. This is correct behavior but can surface surprising compile errors if the modules expect different APIs. The solution is the same as in regular module development: align your dependency versions.
Gotcha #4: replace directives in go.mod still work — and still cause chaos
You can have both replace in go.mod and use in go.work pointing to different paths. The workspace directive wins. This is confusing when you inherit a repo where someone already had replace directives in place. Audit go.mod files when introducing workspaces — clean out the local replace lines.
Gotcha #5: Editors need to know
VS Code with the Go extension and GoLand both support workspace mode, but they need to detect the go.work file. If you open a sub-directory (e.g., just api-server/) instead of the workspace root, the editor won’t see the workspace and will show import errors for your local modules. Always open the workspace root in your editor.
Production-Ready Patterns
Pattern 1: Separate versioning with a shared workspace
Each module has its own git tags and version. go.work is for local development only, gitignored:
# .gitignore
go.work
go.work.sum
Module consumers pin specific published versions. Developers run go work init locally. This is the "library + consumer" model where you want each module independently releasable.
Pattern 2: Workspace committed, GOWORK=off in CI
For true monorepos where everything moves together, commit go.work. All developers work with workspace mode on. CI uses GOWORK=off and requires that every go.mod lists real versions. You enforce version consistency via a pre-commit hook or CI check that runs go mod verify with workspace disabled.
Pattern 3: go work sync
go work sync
This command updates the go.sum files of all workspace modules to include checksums for the module graph as resolved by the workspace. Run it after pulling changes that modify dependencies in any workspace module. Think of it as the workspace-aware equivalent of running go mod tidy everywhere.
Pattern 4: Tooling integration
If you have scripts that run go generate, golangci-lint, or custom code generators, run them per-module, not from the workspace root. Most tools respect the module boundary. For linting:
for dir in api-server domain eventbus; do
(cd "$dir" && golangci-lint run ./...)
done
When NOT to Use Workspaces
Workspaces are for active cross-module development. They’re not a permanent architectural choice for production module resolution. If you’re just consuming a published library and never editing it, don’t add it to your workspace — let the module proxy do its job.
Also, if your "monorepo" is actually just one Go module with internal packages, you don’t need workspaces at all. A go.work file with a single use . entry is pointless ceremony. Workspaces add value specifically when you have multiple modules that you’re editing concurrently.
Quick Reference
| Task | Command |
|---|---|
| Initialize workspace | go work init ./mod1 ./mod2 |
| Add a module | go work use ./mod3 |
| Remove a module | go work edit -dropuse ./mod3 |
| Disable workspace mode | GOWORK=off go build ./... |
| Sync workspace checksums | go work sync |
| Inspect workspace as JSON | go work edit -json |
| Tidy all modules (bash) | see script above |
The Bottom Line
go.work is the feature that should have existed since modules were introduced. The workflow it replaces — maintaining replace directives, juggling version pins, hoping you don’t commit local paths — was always a footgun. Workspaces give you a clean separation: local development uses the workspace, CI uses the module proxy, and go.mod stays honest.
If you’re building anything with more than one Go module and you aren’t using workspaces, you’re making your life harder for no reason. Set it up once, commit a go.work.sum, add GOWORK=off to your CI env, and move on.