Go Workspaces: Using go.work to Develop Multiple Modules Together
You add a field to a shared library. Now the service that imports it won’t compile until you tag a version, push it, and bump the requirement. Or you add a local replace to go.mod, forget it’s there, and push it.
A go.work file is the way out. It turns several directories on disk into the main modules for minimal version selection. That one sentence explains most of the behavior you care about. Without a go.work file, the workspace is just the single module containing your current directory. With one, the go command resolves imports across every module you listed, so the change in the shared library is visible immediately. No replace lines, no tag, no commit.
The rest of the story is knowing which commands respect the workspace, which ignore it, and why you probably shouldn’t commit the file.
Setting up a go workspace
Say you have two modules in sibling directories: a shared library and a service that uses it.
~/code/
platform-auth/ # module example.com/platform/auth
billing-api/ # module example.com/billing
Create the workspace from the parent directory:
cd ~/code
go work init
go work use ./platform-auth ./billing-api
That produces a go.work file:
go 1.23.0
use (
./platform-auth
./billing-api
)
go work init writes a go directive, and you should keep it. It tells the toolchain which version of the file format to expect, so future format changes can be interpreted correctly. A hand-written file without the directive is still valid, but Go treats it as declaring Go 1.18.
Here’s an illustrative example of what you get. Suppose platform-auth exposes a token parser and you want to add a field to it:
// ~/code/platform-auth/token.go
package auth
// Claims is the decoded payload of a token.
type Claims struct {
Subject string
Scopes []string
TenantID string // new field, not released yet
}
// ~/code/billing-api/handler.go
package main
import (
"fmt"
"example.com/platform/auth"
)
func describe(c auth.Claims) string {
// Resolves against the local platform-auth directory,
// not a published version, because of the workspace.
return fmt.Sprintf("%s in tenant %s", c.Subject, c.TenantID)
}
Run go build ./... or go test ./... from billing-api and it compiles against the on-disk platform-auth, unreleased field included. From a workspace root that is not itself a module, ./... may fail with “directory prefix . does not contain modules listed in go.work”; run the command once in each module, or name the module paths explicitly. The go.mod of billing-api still requires some published version of example.com/platform/auth. The workspace only overrides which copy gets used while you work.
go work use also takes a -r flag, which searches the argument directories recursively for modules and behaves as if each one had been passed explicitly. That’s the one you want in a monorepo with a dozen modules:
go work use -r .
A cleanup detail worth knowing: go work use adds a use directive for each argument directory that exists on disk, and removes the directive if the directory does not exist. Re-running it after deleting a module tidies the file for you.
One gotcha. A use directive does not pick up modules in subdirectories of its argument. Nested modules each need their own use line, or -r.
How the go command decides it’s in workspace mode
This trips people up, so be precise about it. The go command checks the GOWORK environment variable first:
GOWORK=off— single-module context, workspace ignored.GOWORKempty or unset — the command searches the working directory, then each parent directory, for ago.workfile. If it finds one, it uses that workspace. Otherwise the workspace includes only the module containing the working directory.GOWORKset to a path to an existing file ending in.work— workspace mode is enabled with that file. Any other value is an error.
When you’re not sure what’s happening:
go env GOWORK
That prints the go.work file in use, or an empty string if the command is not in workspace mode. The parent-directory search is also why a stray go.work in your home directory can quietly affect every project underneath it.
Commands that ignore the workspace
Most go subcommands that work with modules operate on the set of modules the workspace defines. These do not:
go mod init, go mod why, go mod edit, go mod tidy, go mod vendor, and go get always operate on a single main module.
That matters more than it sounds. go test ./... from the workspace root sees all your local changes, but go mod tidy run inside billing-api resolves requirements as if the workspace didn’t exist. Tidy will never learn about your unreleased platform-auth field. You still have to publish a version and bump the requirement before the module builds on its own.
replace in go.work vs replace in go.mod
Before workspaces, the standard trick for cross-module development was a local replace in go.mod:
replace example.com/platform/auth => ../platform-auth
It works. It also lives in a file you commit, so it’s easy to push by accident, and it breaks anyone whose directory layout differs from yours. A replace directive only applies in the main module’s go.mod and is ignored in other modules, so it doesn’t compose either.
Workspaces change this in three concrete ways. With multiple main modules, all of their go.mod replaces apply, and conflicting replace directives across main modules are disallowed. You have to remove them or override them with a replace in go.work. A replace in go.work overrides replaces of the same module or module version in workspace modules. And a wildcard replace (no version on the left of =>) in go.work overrides a version-specific replace in a go.mod file.
That makes go.work replaces the escape hatch when two modules in your workspace disagree:
go 1.23.0
use (
./platform-auth
./billing-api
)
replace golang.org/x/net => example.com/fork/net v1.4.5
One rule carries over from go.mod: a replace alone does not add a module to the module graph. Something still has to require the module version on the left side, or the replace has no effect.
go work sync and go.work.sum
While a workspace is active, the go command maintains a go.work.sum file holding hashes used by the workspace that aren’t in the collective workspace modules’ go.sum files.
The more interesting command is go work sync. It computes the workspace’s build list, the versions of all transitive dependency modules used for builds in the workspace, using MVS. Then it rewrites each workspace module’s go.mod with the dependencies relevant to that module upgraded to match. Because MVS guarantees the build list’s version of each module is the same or higher than the one in each workspace module, syncing can only move requirements up.
Run it when you want the modules to agree on dependency versions before you cut releases:
go work sync
go test ./... # from each module directory, with GOWORK=off
The GOWORK=off run is the check that actually tells you something. It shows you whether each module builds the way a downstream consumer will see it, rather than the way your laptop sees it.
Should you commit go.work?
The official guidance says committing go.work files is generally inadvisable, for two reasons:
- A checked-in
go.workcan override a developer’s owngo.workfrom a parent directory, so theirusedirectives quietly stop applying. - A checked-in
go.workcan cause CI to select and test the wrong versions of a module’s dependencies. CI should test the module the way it behaves when required by other modules, where ago.workfile inside the module has no effect at all.
So the common setup is to add go.work and go.work.sum to .gitignore, and set GOWORK=off in CI as a belt-and-braces measure.
The docs do carve out an exception. When the modules in a repository are developed exclusively with each other and not alongside external modules, there may be no reason a developer would want a different combination. Committing the file is defensible there, provided the module author still makes sure the individual modules are tested and released properly. I’d still be nervous about the CI half of the argument, but the reasoning holds for a self-contained monorepo.
A clean workflow for coordinated changes
Putting it together, the loop for changing several modules at once:
go work initandgo work use(orgo work use -r .) from the directory above your modules. Keep the file out of version control.- Edit across modules freely. Build and test from each module directory, or pass patterns such as
./platform-auth/... ./billing-api/...from the workspace root, so imports resolve to local code without relying on a root-level./...pattern. - Before releasing, verify each module on its own with
GOWORK=off go build ./...andGOWORK=off go test ./.... This catches the case where your code only compiles because of the workspace. Running fuzz tests against the isolated build is worth the time here too. - Tag and publish the lower-level module. Then run
go getin the consumer to bump the requirement. It operates on one main module, although a modern toolchain may also raise the workspace’sgoline when needed to keep the workspace valid. - Optionally run
go work syncfirst, to align dependency versions across the workspace modules before any of that.
If you want to automate the file editing, the golang.org/x/mod/modfile package is documented as the way for Go programs to make the same changes programmatically that go work edit makes from the command line. Generating a workspace file for a monorepo from a manifest is a reasonable use of it. Your editor benefits from an accurate workspace as well, so if you live in gopls, the multi-step code actions post pairs well with this one.