The replace directive trap
You are building a service that depends on a library you also maintain. You fix a bug in the library and want to test the service immediately. You edit the service's go.mod file and add a replace directive pointing to the local library directory. You run the tests. They pass. You push the code. The CI server does not have the local directory structure. The build fails. You remove the replace directive, push again, and realize the library fix was incomplete. You add the replace directive back.
This cycle wastes time and risks committing local paths to production. If you have three modules that depend on each other, you end up scattering replace directives across multiple go.mod files. You forget to remove them. You merge conflicts when updating dependencies. The module graph becomes a tangle of local overrides that only work on your machine.
Go workspaces solve this problem. A workspace groups multiple modules under a single go.work file. The toolchain reads this file and automatically maps module paths to local directories. You never touch go.mod to test local changes. The workspace acts as a temporary override layer that lives only on your machine.
What a workspace actually does
A workspace is a collection of modules managed by a single configuration file. When you run go build, go test, or go vet, the toolchain checks the go.work file first. If a dependency matches a module listed in the workspace, the toolchain loads the local directory instead of fetching a version from the module proxy.
Think of go.mod as the contract with the world. It declares which versions of dependencies the module requires. A workspace is like a local staging area. It tells the compiler to use the code sitting in your filesystem for specific modules, while leaving the contracts untouched. The go.mod files remain valid for publishing and for other developers who do not have your local changes.
Workspaces also manage a unified dependency graph. They aggregate the requirements of all included modules and resolve conflicts. This means you can update a shared dependency once and have the change propagate across all modules in the workspace.
The workspace is a developer tool. It does not change the module contract.
Setting up a workspace
Workspaces are initialized at the root of your project tree, typically one level above the individual modules. The go work command handles creation and management.
Here's how you bootstrap a workspace.
# Initialize the workspace. This creates a go.work file in the current directory.
go work init
# Add modules to the workspace. Paths are relative to the go.work file.
go work use ./cmd/server ./pkg/auth
The go work init command creates a go.work file with the current Go version. The use command registers modules. You can add as many modules as needed. The toolchain verifies that each path contains a valid go.mod file.
Here's the resulting workspace file.
// go.work
go 1.21
use (
// These paths point to directories containing go.mod files.
// The toolchain treats these as the source of truth for their module paths.
./cmd/server
./pkg/auth
)
The go directive sets the minimum Go version required for the workspace. The use block lists the modules. Paths must be relative to the go.work file. You can also use go work use -r to recursively add all modules found in subdirectories. This is useful for large monorepos with deeply nested structures.
Initialize once. Add modules as you start working on them.
How the toolchain resolves dependencies
When you run a command inside a workspace, the toolchain follows a specific resolution order. It checks the go.work file to see if the requested module is listed in the use block. If it is, the toolchain loads the local directory. If not, it falls back to the standard module resolution process, checking the local module cache and then the proxy.
The workspace also generates a go.work.sum file. This file aggregates the cryptographic hashes of all dependencies across all modules in the workspace. It ensures that the workspace build is reproducible. If you share the workspace file in a monorepo, the sum file guarantees that every developer gets the exact same dependency versions.
The interaction with go mod tidy is where workspaces shine. Running go mod tidy inside a module that is part of a workspace behaves differently than usual. The tidy command sees the workspace overrides and knows not to add replace directives. It leaves the go.mod file clean. With manual replace directives, tidy might complain or you have to manually manage the directives. With a workspace, tidy just works.
Tidy respects the workspace. Your go.mod files stay clean.
Realistic workflow: syncing dependencies
Workspaces make it easy to keep dependencies consistent across modules. If you update a dependency in one module, you can propagate that version to all other modules in the workspace.
Here's how you sync versions across modules.
# Fetch a new version of a dependency. This updates go.work.sum and the go.mod files in the workspace.
go get github.com/example/pkg@v1.2.0
# Sync the resolved versions back to each module's go.mod file.
go work sync
The go get command updates the workspace state. It adds or updates the dependency in go.work.sum and modifies the go.mod files of modules that require the package. The go work sync command ensures that all modules in the workspace use the same version of shared dependencies. It updates require directives in each go.mod file to match the workspace resolution.
This is particularly useful when you have multiple services that share a common library. You can update the library version once and sync it everywhere. You avoid the drift where one service uses version 1.1.0 and another uses 1.2.0.
Sync versions early. Divergent dependencies across modules cause subtle bugs.
Pitfalls and gotchas
Workspaces simplify development, but they introduce a few nuances. Understanding these prevents confusion when things go wrong.
If you reference a directory in use that lacks a go.mod file, the compiler rejects the workspace with go: module path not found. Ensure every path in the use block points to a valid module root.
If you try to build a module that is not in the workspace while the workspace is active, you might get go: module X is not in workspace. The toolchain expects all modules you are working on to be registered. Add missing modules with go work use.
Workspaces do not automatically update go.mod files when you add a module. You still need to run go get or go work sync to update dependencies. The workspace manages the mapping, but the go.mod files still declare the requirements.
If you have replace directives in go.mod files that conflict with the workspace, the workspace takes precedence. However, this redundancy is messy. Remove replace directives from go.mod files when you add the modules to a workspace. The workspace handles the overrides.
Check your go.work file before blaming the compiler.
When to use workspaces
Choosing the right tool depends on your project structure and workflow. Workspaces are powerful, but they are not always necessary.
Use a workspace when you are developing multiple modules simultaneously and want to test changes without modifying go.mod files. Use a replace directive when you need a one-off override for a single module in a project that does not use a workspace. Use a single module when your project is small enough that splitting it adds more complexity than value. Use a workspace with go work sync when you want to standardize dependency versions across several modules in a monorepo.
Workspaces reduce cognitive load. Use them to focus on code, not configuration.