Workspaces and Multi-Module Development
Workspaces and Multi-Module Development
Most desk tools are one module. A workspace (go.work) is how you develop two or more modules on disk as one build without committing replace lines. The boring default is still a single go.mod. Split modules when they have different versions or different consumers — not because the folder feels busy.
Mental model
A workspace is a go.work file that lists local module directories:
go 1.27
use (
./desk
./prices
)
Inside that workspace, an import of example.com/prices resolves to ./prices, not to a downloaded zip. You do not need a replace in desk/go.mod.
replace in go.mod is the older hammer: it rewrites an import path for everyone who builds that module. Fine for a fork you control. Bad as “please use my cousin’s checkout.” A workspace is local to the developer. Commit go.work only when the repository is the workspace (a small number of private modules that always move together). Do not commit go.work into a public library that strangers will go get.
Worked examples
Case 1: One module is enough
This is the default. One go.mod, one main.go, prices inlined. No workspace.
go.mod:
module example.com/desk
go 1.27
Save as main.go:
// main.go
package main
import "fmt"
func price(item string) int {
switch item {
case "tea":
return 3
case "toast":
return 4
default:
return 0
}
}
func main() {
fmt.Println("tea", price("tea"))
}Run:
go run .Output:
tea 3
Stop here if price is only used by this program. A second module would be ceremony.
Case 2: Two modules and a workspace
When prices is a separate module (another repo, another version, another binary that also imports it), check both out as siblings and put a workspace above them. Create the two go.mod files before go work init — an empty directory is not a module, and init will not add it.
mkdir -p work/desk work/prices
cd workprices/go.mod:
module example.com/prices
go 1.27
Save as prices/prices.go:
// prices.go
package prices
func For(item string) int {
switch item {
case "tea":
return 3
case "toast":
return 4
default:
return 0
}
}desk/go.mod — no replace. The require is a version name only; the workspace maps it to ./prices:
module example.com/desk
go 1.27
require example.com/prices v0.0.0
Save as desk/main.go:
// main.go
package main
import (
"fmt"
"example.com/prices"
)
func main() {
fmt.Println("tea", prices.For("tea"))
fmt.Println("toast", prices.For("toast"))
}Now, with both go.mod files on disk:
go work init ./desk ./pricesThat writes go.work (the go line may show a patch version such as 1.27.1):
go 1.27
use (
./desk
./prices
)
Run from work/ (the directory that contains go.work):
go run ./deskOutput:
tea 3
toast 4
The require example.com/prices v0.0.0 is a version name only. You did not publish v0.0.0. In the workspace it still resolves to ./prices. There is often no go.sum line for that unpublished path.
go work use ./other adds another module later. go work sync copies the workspace’s selected versions into each module’s go.mod requires.
Case 3: replace instead of a workspace
Same two modules, no go.work. desk/go.mod must rewrite the path:
module example.com/desk
go 1.27
require example.com/prices v0.0.0
replace example.com/prices => ../prices
desk/main.go and prices/ are the same as Case 2. From desk/:
go run .Output:
tea 3
toast 4
It works. The replace is now part of desk’s module file. Anyone who clones only desk gets a broken ../prices. That is why workspaces exist: the override lives in go.work, which you can keep uncommitted (add it to .gitignore) while desk/go.mod stays publishable.
Use replace when you permanently fork a module to a path you do publish (replace example.com/mod => example.com/you/mod v1.2.3). Use a workspace when you are editing two checkouts on your machine.
The trap
Creating go.work because a blog said “monorepo,” then splitting a 200-line desk app into five modules that cannot be built unless the workspace file is present. This program is the whole product. It does not need a sibling module.
// solo.go
package main
import "fmt"
func main() {
fmt.Println("one module")
}Run:
go run solo.goOutput:
one module
The other trap is committing go.work with absolute paths (use /Users/you/src/prices). use directories should be relative to go.work. Absolute paths fail on every other machine.
A third trap: leaving a replace => ../prices in a module you tag as v1.0.0. Downstream go get cannot follow your laptop.
The boring rule
- One module per program until a library has its own consumers or its own versions.
go work init ./a ./bfor local multi-module work. Relativeusepaths.- Prefer a workspace over a committed
replaceto a relative folder. - Commit
go.workonly when the repo is the workspace. Do not commit it into a library strangers import. replaceis for forks you publish, or a temporary patch you will delete.go run ./deskfrom the workspace root. Do not copy source between modules by hand.
Try this
- In Case 1, add a
soupprice and print it. Do not create a second module. - In Case 2, add
prices.For("soup")returning5. Rungo run ./deskfromwork/and fromdesk/(withGOWORK=offfromdesk/it should fail to findexample.com/pricesunless you added a real require+replace). - Move
go.workaside and try Case 3’sreplace. Then delete thereplaceand restorego.work. Notice which file you would be willing to commit.