
Introduction
go.work feels frustrating because it changes how the Go command resolves dependencies, and it does so silently, based on where you run the command. A build that passes on your machine can fail in CI, and a build that fails on your machine can pass for a teammate, with no code change in between. The cause is usually not a broken package. It is workspace mode being on when you expected it off, or off when you expected it on.
Before You Start
- Module: a collection of packages in a file tree with a
go.modfile at its root. The module path ingo.modis the import path prefix for its packages. - Main module: in single module mode, the module containing the directory where the
gocommand runs. In workspace mode, every module listed ingo.workis a main module. - Workspace: a collection of modules on disk that are used as main modules when the Go command runs minimal version selection. A workspace is declared in a
go.workfile, which lists relative paths to module directories. - Minimal version selection: the rule the Go command uses to pick one version of each dependency, from the requirements in the main modules and their dependencies. In workspace mode, the workspace modules take part in that selection as main modules.
- go.work: a file with
go,use, and optionallyreplacedirectives. It is not a module, it has no packages, and it cannot be imported. Itsusedirectives list module directories, relative to the directory holding thego.workfile. - GOWORK: an environment variable that controls workspace mode.
GOWORK=offforces single module mode. An empty value means thegocommand searches upward for ago.workfile. A value naming an existing file ending in.workenables workspace mode with that file. - Replace directive: a directive that redirects an import path, or a module version, to another version or to a local directory. A
replacein ago.modfile applies only when that module is a main module. Areplacein ago.workfile applies across the workspace, and overridesreplacedirectives in workspace modules for the same module or version. - go.work.sum: a checksum file for the workspace build list. It holds sums that the individual workspace modules'
go.sumfiles do not cover on their own. - Vendoring: storing dependency source code in a
vendordirectory inside a module, so builds use that copy instead of the module cache. In workspace mode, module vendor directories are ignored.
Prerequisites
- Go 1.20 or later. Workspaces arrived in Go 1.18, so Go 1.20 is a safe baseline, and no newer version is required for anything in this article.
- A terminal, and a text editor.
- Two local modules, as in the complete example. No third party packages are required.
go version
Requires: Go 1.20 or later. Verified on 2026-10-25 against the official sources in References.
Project Layout
gowork-demo/
├── go.work # workspace file, local development only
├── .gitignore # keeps go.work and go.work.sum out of commits
├── greet/
│ ├── go.mod # module example.com/greet
│ ├── greet.go # the shared package
│ └── greet_test.go # tests for the shared package
└── app/
├── go.mod # module example.com/app
└── main.go # the program that imports greet
- go.work: lists
./greetand./appas workspace modules. - .gitignore: lists
go.workandgo.work.sum, so the workspace stays on the developer machine and out of the repository. - greet/: a small library module, with its own
go.mod, so it can be developed and released on its own. - app/: a program module that imports
example.com/greet, with its owngo.mod, so it can be built on its own, without a workspace.
What A Workspace Is For
A workspace is for developing several related modules at the same time, on one machine, without publishing an intermediate version of one module so another module can use it. The concrete case is a library and a program that uses it, both under active development, in separate modules, in one repository or in neighbouring checkouts.
Before workspaces, the usual local development trick was a replace directive in the program's go.mod, pointing the library import at a local directory. That trick works, and it has a cost: the replace lives in a file that is meant to be committed, so it has to be added for local work and removed before a commit, or it ships a local path to everyone else. A workspace moves that local redirection out of go.mod and into go.work, a file that is meant to stay local.
In workspace mode, an import of a workspace module resolves to the local copy on disk, not to a downloaded version from the module cache. A change in the library is visible to the program on the next build, with no publish step, no version bump, and no replace edit in go.mod. That is the whole job of a workspace, and it is a development job, not a release job.
What A Workspace Is Not For
A workspace is not a way to ship code to consumers, and it is not a release mechanism. When a consumer imports your library as a dependency, a go.work file inside your module has no effect on that consumer's build. The Go command resolves the library from the published version in the consumer's own module graph, not from a directory on your machine. The workspace only matters to someone building inside your checkout, which is why a committed go.work file changes checkout builds, but never travels as a dependency. If your library only builds inside your workspace, because its go.mod is missing a requirement that the workspace happens to supply, the consumer build breaks, and the workspace hid that break from you.
A workspace is also not, by default, a CI setup. CI checks out a fresh repository, and if go.work is committed, CI runs in workspace mode, with whatever the workspace file says. If go.work is not committed, CI runs in single module mode, one module at a time. Both are defensible, but they are different builds, and a project that tests only one of them has not tested the other. The clean CI choice for a library, and for a program that consumes published modules, is single module mode, with GOWORK=off, so CI sees the build exactly as a consumer sees it.
A workspace is not a substitute for a release either. The official workspaces tutorial ends with the same point: to release properly, tag the library module, then raise the requirement in the consuming module to that tagged version, so the go command can resolve the modules outside the workspace. Until that step happens, the program depends on local code that no one else can fetch.
How The Go Command Finds A Workspace
The go command decides whether it is in workspace mode before it resolves anything, and the decision follows a fixed order, from the Go Modules Reference:
- If
GOWORKis set tooff, the command runs in single module mode. - If
GOWORKis empty or not set, the command searches the current directory, then each parent directory, for a file namedgo.work. If it finds one, it runs in the workspace that file defines. If it finds none, the workspace is the single module containing the working directory. - If
GOWORKnames a path to an existing file ending in.work, workspace mode is enabled with that file. Any other value is an error.
Two consequences of that order explain most workspace confusion. First, a go.work file in a parent directory applies to every command run in every subdirectory below it, even when the command is run from inside one module, and even when the developer forgot the workspace exists. Second, the workspace in use is not written in any output by default, so a surprising build gives no hint that workspace mode is the cause, unless the developer checks.
The check for which workspace, if any, is in use is one command:
go env GOWORK
If the output is empty, or it prints off, the go command is not in workspace mode. If it prints a path, that is the go.work file in use. That command belongs in the debugging routine for any build that behaves differently in different directories, or on different machines, with the same code.
How Version Selection Changes In Workspace Mode
In single module mode, one module is the main module, and its requirements drive version selection. In workspace mode, every workspace module is a main module, and the build list is built from all of them together. That changes three things at once.
First, a workspace module is always selected, at version "", rather than at the version named in a require directive. An import of a workspace module therefore resolves to local disk, even when the requiring module names an older published version. That is the feature, and it is also the trap: a program can pass in workspace mode while depending on library code that has never been published, and no published version exists to satisfy the requirement outside the workspace.
Second, requirements from all workspace modules are combined. If two workspace modules require different versions of the same third party dependency, minimal version selection picks the higher version for the whole workspace build, and both modules build against that version. A module that passes alone, against the lower version, can fail in the workspace, against the higher version, and the reverse is also possible. The workspace build is a different build from each module's standalone build, not a sum of them.
Third, replace and exclude directives in workspace modules are applied in workspace mode, because workspace modules are main modules. A replace in a go.work file applies in addition, and with higher precedence, over replace directives in workspace modules for the same module or version. If two workspace modules replace the same module or version with different targets, and no go.work replace overrides them, the go command reports an error, instead of picking one silently. That error is a signal, not a bug: two main modules disagree about where a dependency comes from, and the workspace file is the place to settle it.
Replace Versus Workspace
A replace in go.mod and a workspace solve the same local development problem in different places, with different lifetimes.
A replace in go.mod is part of the module definition. It applies only when that module is a main module, so it does not propagate to consumers, but it is still a file that is normally committed, and a local path in it ships to everyone who clones the repository. It is the right tool when the redirection is part of the module's own story, for example a fork that the module genuinely depends on, and it is the wrong tool for a temporary local checkout, because it has to be remembered and removed.
A workspace keeps the redirection in go.work, a file that is not part of any module, and that the repository can ignore. Each developer can have a different workspace layout, with different local paths, without touching any go.mod file. That is why the task's clean setup uses one workspace per developer machine, not one workspace in the repository.
The two can also combine, and that combination is where precedence matters. A replace in go.work overrides a replace in a workspace module for the same target. So a developer who needs a fork for local testing can put that fork in go.work, without editing any module, and the workspace module's own replace for the same target loses. When the local test is done, removing the go.work replace restores the module's own behaviour, with no go.mod edit to revert.
One rule from the nested modules topic still applies here, and it is worth one sentence, not a repeat of that article: a replace in a dependency module, one that is not a main module, is ignored. Workspace mode does not change that rule for non workspace modules. It changes it only by making workspace modules main modules, so their replace directives count.
The go.work.sum File And Vendoring
Checksums in workspace mode come from the collective go.sum files of the workspace modules, plus go.work.sum where those files do not cover the workspace build list. The case the Modules Reference describes is a module that requires a dependency version without importing its packages, so its go.sum holds the checksum for that version's go.mod file, but not for its source code. In a workspace build, another module may need that source code, because the workspace build list selects the higher version, and then the missing source checksum goes into go.work.sum, not into any single module's go.sum.
That split is why go.work.sum belongs with go.work in .gitignore, when the workspace itself is local. Committing go.work without go.work.sum, or the reverse, leaves the next checkout with a workspace whose checksums do not match its build list, and the go command will add or complain about sums at build time, instead of building cleanly.
Vendoring behaves differently again, and this is verified from the workspace design document, not from a guess: module vendoring is ignored in workspace mode. The reason given there is that it is not clear which modules' vendor directories should be respected when several workspace modules vendor the same dependency at different versions, and a workspace build list can select a version that no vendor directory holds. The workspace vendor command is go work vendor, which vendors for the workspace as a whole, instead of per module. A project that relies on per module vendor directories should therefore test its vendored build in single module mode, with GOWORK=off, and treat a workspace build as a separate, non vendored build.
Two further commands have a workspace scope worth stating plainly, from the Modules Reference: 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, even in workspace mode. So go mod tidy run inside one workspace module tidies that module, not the workspace, and go work sync is the command that pushes the workspace build list back into each workspace module's go.mod file. Running tidy and expecting the whole workspace to stay in sync is a category error, and go work sync is the command that does the syncing job.
Design Decisions And Tradeoffs
| Option | Use when | Cost |
|---|---|---|
| Workspace, local only | Developing several related modules together, on one machine | Each developer maintains a workspace, and CI must test the non workspace build too |
| Workspace, committed | A repository wants one shared multi-module build, and every checkout uses it | Every command below the workspace root runs in workspace mode, and consumers cloning the repo inherit it |
| Replace in go.mod | A redirection is part of the module's own definition, such as a fork | A local path can ship in a committed file, and must be removed before release |
| Replace in go.work | A temporary local redirection, such as testing a fork, without editing any module | The redirection is invisible to anyone without the workspace file, and to CI without it |
| GOWORK=off for release and CI | Checking the build as a consumer sees it, or as a single module sees it | Local workspace changes are invisible to that build, so a workspace only fix will not show up |
| go work sync | Pushing workspace dependency versions back into each module's go.mod | It rewrites module files, so it should run deliberately, not as a side effect of a build |
| Vendor per module | Reproducible offline builds of a single module | Vendor directories are ignored in workspace mode, so a workspace build is not a vendored build |
A design in this article has a deliberate compromise, stated openly. The complete example uses two tiny modules with no third party dependencies, so the workspace mechanics can be read without a dependency graph in the way. In a real project, the same rules apply with a real graph, and the version selection section is where that graph changes the outcome. The two module example is a teaching compromise, not a production layout. The workspace rules themselves are not compromised: the workspace stays local, the release step raises the requirement to a published version, and the release build runs with GOWORK=off.
Complete Example
The complete example is a library module and a program module, in one workspace, on one machine. The program imports the library. In workspace mode, that import resolves to local disk. Outside workspace mode, it resolves to the published version named in the program's go.mod, once that version exists.
gowork-demo/
├── go.work
├── .gitignore
├── greet/
│ ├── go.mod
│ ├── greet.go
│ └── greet_test.go
└── app/
├── go.mod
└── main.go
Create the workspace file, or generate it with go work init, from the gowork-demo directory:
go work init ./greet ./app
go work sync
// go.work
go 1.20
use (
./greet
./app
)
# .gitignore
go.work
go.work.sum
// greet/go.mod
module example.com/greet
go 1.20
// greet/greet.go
package greet
func Hello(name string) string {
if name == "" {
return "Hello, world"
}
return "Hello, " + name
}
// greet/greet_test.go
package greet
import "testing"
func TestHello(t *testing.T) {
if got := Hello("Ada"); got != "Hello, Ada" {
t.Errorf("Hello(\"Ada\") = %q, want %q", got, "Hello, Ada")
}
if got := Hello(""); got != "Hello, world" {
t.Errorf("Hello(\"\") = %q, want %q", got, "Hello, world")
}
}
// app/go.mod
module example.com/app
go 1.20
app/go.mod deliberately has no require for example.com/greet. In workspace mode the workspace supplies the library module, so no requirement is needed for the workspace build. Do not add a placeholder v0.0.0 requirement to fill that gap: a workspace build still passes with one, but go work sync and go list -m all fail while they try to resolve the unpublished version, so the example keeps the requirement out until release. Outside workspace mode, the build fails because no published version is required at all, with the error no required module provides package example.com/greet. That failure is the release check doing its job, not a workspace bug.
// app/main.go
package main
import (
"fmt"
"example.com/greet"
)
func main() {
fmt.Println(greet.Hello("Ada"))
}
Commands to run it, from the gowork-demo directory, in workspace mode:
go run ./app
Expected output:
Hello, Ada
Why this program prints that output, in order:
- The
gocommand findsgo.workin the current directory, so it runs in workspace mode, withgreetandappas main modules. - The import of
example.com/greetinapp/main.goresolves to the localgreetmodule, not to a downloaded version, because workspace modules are always selected. - A change in
greet/greet.gois visible on the nextgo run ./app, with no publish step and no edit to anygo.modfile.
Release steps, in order, starting from the workspace directory and ending inside app:
cd app
go get example.com/greet@v0.1.0
GOWORK=off go run .
The go get step raises the requirement in app/go.mod to the published v0.1.0, after that version is tagged in the library's repository. The GOWORK=off step, run from inside app, then builds the program exactly as a consumer builds it, from the published version, with no workspace in play. The workspace root has no go.mod of its own, so a GOWORK=off build from that directory fails before it reaches the program. If that build fails, the release is not ready, even if the workspace build passed.
Testing Workspaces
Test a workspace in two modes, because they are two different builds, and a pass in one does not prove a pass in the other.
go test ./greet/...
GOWORK=off go test ./...
The first command runs the library tests in workspace mode, from the workspace root. The second command, run from inside a module directory, runs that module's tests in single module mode, as a consumer's build would see them. Run the second command from inside greet for a passing single module test run. From inside app, the same command fails until a published version is required, and that failure is the release gate, not a test failure. A library that passes only in workspace mode may be missing a requirement in its own go.mod, because the workspace supplied it from another module. A program that passes only in single module mode may be depending on a published version that does not yet hold the library change under development.
Commands to check the workspace itself, before blaming code:
go env GOWORK
go list -m all
go env GOWORK prints the workspace file in use, or nothing, or off, when workspace mode is off. go list -m all prints the build list for the current mode, so a comparison of its output in workspace mode and with GOWORK=off shows exactly which versions changed when the workspace was switched on. Run the workspace mode list from the workspace root, and the GOWORK=off list from inside a module directory, since the workspace root has no go.mod of its own. That comparison is the fastest way to tell a workspace version problem apart from a code problem.
go vet and go test do not detect a workspace problem on their own. They run in whichever mode is in effect, and they pass or fail in that mode. The mode check above is the diagnostic, and the two mode test run is the proof for this example.
Common Mistakes
- Committing go.work and expecting consumers to ignore it: a committed
go.workfile puts every checkout below it into workspace mode, including CI checkouts and consumer clones that only wanted one module. The fix is.gitignoreforgo.workandgo.work.sum, unless the repository deliberately wants a shared workspace, and then that choice should be documented in the repository. - Running a command from a subdirectory and forgetting the parent workspace: a
go.workfile in a parent directory applies to commands run in subdirectories, so a module build from inside the module can still be a workspace build. The fix isgo env GOWORK, before debugging the code. - A checked in go.work overriding a developer's own workspace: the search stops at the first
go.workfile found walking up, so a committedgo.workin a repository root overrides a developer's owngo.workin a parent directory, and that developer'susedirectives stop applying. The fix is to keepgo.workout of the repository, or to pointGOWORKat the intended workspace file explicitly. - Treating a workspace pass as a release pass: a program that passes in workspace mode can still fail for a consumer, because the workspace supplied local code that has never been published. The fix is a
GOWORK=offbuild, after raising the requirement to a published version. - Leaving a placeholder version in go.mod: a
v0.0.0requirement, or any unpublished version, lets a workspace build pass whilego work syncandgo list -m allfail to resolve it, and it fails outside workspace mode. The fix is to leave the requirement out until release, as the complete example does, to treat theGOWORK=offfailure as a release gate, and to publish before claiming the program is ready. - Expecting go mod tidy to tidy the workspace:
go mod tidyalways operates on a single main module, even in workspace mode, so it does not sync the whole workspace. The fix isgo work sync, which pushes the workspace build list into each workspace module'sgo.modfile. - Forgetting go.work.sum in the ignore list: committing
go.workwithout its sums, or ignoringgo.workwhile committinggo.work.sum, leaves a workspace whose checksums do not match its build list. The fix is to ignore both together, when the workspace is local, or to commit both together, when the workspace is shared deliberately. - Mixing replace and workspace without checking precedence: a
replaceingo.workoverrides areplacein a workspace module for the same target, and two workspace modules replacing the same target differently produce an error without an overridinggo.workreplace. The fix is to put the intended redirection ingo.work, in one place, and to read the error as a disagreement between main modules, not as a tool failure. - Depending on vendor directories inside a workspace: module vendor directories are ignored in workspace mode, so a build that relies on vendored code can behave differently in a workspace from its vendored single module build. The fix is to test the vendored build with
GOWORK=off, and to usego work vendorfor a workspace level vendor tree, when a vendored workspace build is genuinely needed. - Adding a module to go.work and forgetting its go.mod: a
usedirective points at a module directory, and a directory without ago.modfile is not a module. The fix is to initialise the module first, withgo mod init, then add it withgo work use. - Using a workspace to share code between unrelated projects: a workspace ties modules together on one machine, and a shared workspace across unrelated projects couples their version selection. The fix is one workspace per related set of modules, on the developer machine that is working on that set, and a published version for everything else.
Production Notes
- CI: run the release and consumer facing builds with
GOWORK=off, explicitly, in the CI environment, so a straygo.workfile in the checkout cannot silently switch the build to workspace mode. If the repository deliberately commits a workspace, run both modes in CI, and name them as separate jobs, so a failure states which mode failed. - Releases: tag the library module first, raise the requirement in consuming modules to the tagged version, then build with
GOWORK=off. A release checklist that skips theGOWORK=offbuild has not checked the artefact consumers will actually fetch. - Observability: when a build behaves differently in two directories or on two machines, the first diagnostic is
go env GOWORK, not a dependency upgrade. A difference in that output explains a difference in the build list, andgo list -m allin both modes shows the versions that changed. - Team layout: one workspace per developer machine, for the set of modules that developer is changing together, keeps local paths out of the repository and keeps each developer's version selection independent. A shared workspace file in the repository is a team decision, with a cost in surprise builds for anyone who clones it for a single module.
- Deployment: the example needs Go 1.20 or later, and workspaces need Go 1.18 or later. Pin the Go version in each
go.modfile and ingo.work, rungo vet ./...andgo test ./...inside each module, in both modes, in continuous integration, and do not deploy a change that fails theGOWORK=offbuild. A workspace pass alone is not enough for a release.
Decision Guide
- When developing two or more related modules together, on one machine: use a local workspace, with
go.workandgo.work.sumin.gitignore, andgo work syncto push versions back into modules. - When a redirection is temporary, for local testing only: put it in
go.workas areplace, not in anygo.mod, so it can be removed without a module edit. - When a redirection is part of a module's definition: put it in that module's
go.mod, and accept that it ships in the module, with a local path only when that path is genuinely part of the story. - When checking a release, a consumer build, or a CI build: use
GOWORK=off, after raising requirements to published versions, so the build sees what consumers see. - When a build differs between directories or machines: run
go env GOWORKfirst, then comparego list -m allin workspace mode and withGOWORK=off. - When a repository wants one shared multi-module build: commit
go.workandgo.work.sumtogether, document that choice, and run both modes in CI, so the shared workspace is a decision, not an accident.
Conclusion
go.work is frustrating when it is treated as a project file, a release file, or a hidden default, because it is none of those things. It is a local development mode, switched on by a file found while walking up the directory tree, and switched off by GOWORK=off. Once that mode is visible, with go env GOWORK and a go list -m all comparison, the surprises turn into ordinary version selection: workspace modules win locally, published versions win outside, and the release step is the bridge between them.
Summary Of Key Points
- Workspace mode is a mode, not a module: a
go.workfile lists local modules that become main modules, and it has no packages of its own. - Discovery walks up: an empty
GOWORKmeans thegocommand searches the current directory and its parents forgo.work, so a parent workspace applies to subdirectories. - GOWORK=off is the release check: single module mode shows the build as a consumer sees it, and a workspace pass does not prove a release pass.
- Workspace modules win locally: in workspace mode, a workspace module is always selected, so local code replaces any published version named in a requirement.
- Version selection is combined: requirements from all workspace modules feed one build list, so a workspace build differs from each module's standalone build.
- Replace precedence is explicit: a
go.workreplace overrides workspace module replaces, and conflicting module replaces produce an error without an overriding workspace replace. - Sync is a command, not a side effect:
go mod tidytidies one module, andgo work syncpushes the workspace build list into each module. - Keep it local unless shared deliberately:
go.workandgo.work.sumbelong in.gitignorefor a developer workspace, and both belong in a commit together for a shared workspace.
Keep the workspace on your machine, test the release build with GOWORK=off, and go.work stops being a source of surprise builds, and becomes what it was built for: a way to develop related modules together, without publishing every intermediate change.
Related Posts
Learn more:
Additional Resources
- Go Documentation: Tutorial: Getting started with multi-module workspaces - creating a workspace, adding a local module, and releasing with a published version requirement
- Go Documentation: Go Modules Reference, Workspaces - workspace definition, GOWORK discovery order, workspace modules as main modules, and go.work.sum handling
- Go Documentation: Managing dependencies - requirements, upgrades, and replace directives in module mode
- Go Documentation: Get familiar with workspaces - workspace commands, GOWORK behaviour, and go.work syntax from the Go team
References
- Tutorial: Getting started with multi-module workspaces. Go team, Go Documentation. https://go.dev/doc/tutorial/workspaces. Accessed 2026-10-25.
- Go Modules Reference. Go team, Go Documentation. https://go.dev/ref/mod#workspaces. Accessed 2026-10-25.
- Managing dependencies. Go team, Go Documentation. https://go.dev/doc/modules/managing-dependencies. Accessed 2026-10-25.
- Get familiar with workspaces. Go team, Go Blog. https://go.dev/blog/get-familiar-with-workspaces. Accessed 2026-10-25.