3.7 Go Workspaces (1.18+): Multi-Module Development
Alright, let’s talk about Go Workspaces. You’ve been there, right? You’re hacking on a big project, maybe a monorepo, with a dozen different Go modules. example.com/foo, example.com/bar, example.com/bar/v2—the whole gang. You need to make a change in foo that bar depends on. The old dance was miserable: you’d cd into foo, run go mod edit -replace example.com/foo=../foo, pray, then do the same in any other module that needed the change. It was a tedious, error-prone mess that littered your go.mod files with temporary junk you had to remember to remove before committing. It sucked.
Go 1.18 looked at this circus and said, “We can do better.” Enter workspaces. The core idea is brilliantly simple: you create a single file, go.work, at the root of your project that tells the go command, “Hey, for all operations in this directory tree, instead of looking up to the heavens for modules on the internet, use these specific local copies on my machine.” It’s a local override system that doesn’t require you to defile your actual go.mod files. It’s for development, and it stays on your machine. Thank goodness.
The go.work File: Your Project’s Blueprint
A go.work file is structurally similar to a go.mod file. You can generate one with go work init and then add modules to it with go work use [module_dir]. Let’s say you have this directory structure:
~/dev/my-big-project/
├── api/
│ └── go.mod # module: example.com/api
├── services/
│ └── auditor/
│ └── go.mod # module: example.com/auditor
└── libs/
└── logging/
└── go.mod # module: example.com/logging
The auditor service depends on both api and logging. To start a workspace that uses your local versions of all these modules, you’d do this:
cd ~/dev/my-big-project
go work init ./api ./libs/logging # Initialize with some modules
go work use ./services/auditor # Add another module later
This generates a go.work file that looks something like this:
go 1.21
use (
./api
./libs/logging
./services/auditor
)
The use directive lists the relative paths to the directories containing the modules that form your workspace. Now, when you run go run ./services/auditor from the root directory, the go command sees the go.work file and knows to resolve any import of example.com/api or example.com/logging to the local directories you specified, not the version that might be tagged in your VCS. You can now freely edit code in api and logging and have those changes immediately available to the auditor service. It completely changes the multi-module development workflow for the better.
Why go.work Trumps replace (Almost Always)
The replace directive in a go.mod file is a blunt instrument. It’s permanent, checked into source control, and affects everyone who uses the module. It’s for permanently forking a dependency, not for temporarily developing against a local copy. Using replace for local development was a hack we all tolerated because it was the only option.
A go.work file is the precise opposite. It’s local, it’s temporary, and it’s personal. It’s the right tool for the job. The biggest pitfall here is accidentally committing your go.work file to the repository. You must add go.work and go.work.sum to your .gitignore. Seriously, do it now. These files are not for the repository; they are for your machine and your current task. If you need a reproducible build that uses local replaces, that’s what the replace directive in go.mod is actually for.
The GOTCHA: Running Commands Inside a Module Directory
Here’s a subtle edge case that will bite you. The go.work file is only in effect when you run go commands from the directory that contains it or any subdirectory. But watch what happens:
~/dev/my-big-project$ go run ./services/auditor # This uses the workspace!
~/dev/my-big-project$ cd services/auditor
~/dev/my-big-project/services/auditor$ go run . # This ALSO uses the workspace!
# But what if you 'cd' elsewhere?
~/dev/my-big-project/services/auditor$ cd /tmp
/tmp$ go run ~/dev/my-big-project/services/auditor # This does NOT use the workspace!
The rule is simple: the go command finds the go.work file by traversing up the directory tree from where you invoked the command. If you’re in /tmp, it won’t find your project’s go.work file and will fall back to using the published versions of your modules. This is usually what you want, but it can be confusing if you’re not expecting it. The best practice is to always run your commands from the workspace root. It keeps things predictable.
Workspaces and Your Editor
This is the part where the magic truly happens, but also where you might need to tweak some settings. Your editor’s language server (gopls) needs to be aware of your workspace. If you open your editor at the project root (where the go.work file lives), gopls should automatically detect it and understand the multi-module setup. All your “Go to Definition” and autocomplete will work seamlessly across the local modules.
However, if you open your editor directly inside the services/auditor directory, gopls might not find the go.work file and will treat it as a single, isolated module. If your tooling seems dumb and can’t find your local example.com/api package, this is probably why. The fix is almost always to just open your editor at the higher-level directory containing the go.work file. It’s a small price to pay for the immense functionality you get.