Go Workspaces (go.work): The Right Way to Manage Multi-Module Repos

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 five go.mod files
  • It bleeds into git history if you slip up even once
  • CI/CD systems will refuse to fetch a module with a local replace pointing 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 service
  • domain — shared domain types and interfaces
  • eventbus — 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.

Leave a comment

👁 Views: 2,289 · Unique visitors: 1,646