Workspaces and Multi-Module Development

Updated

September 13, 2026

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 work

prices/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.modno 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 ./prices

That 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 ./desk

Output:

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.go

Output:

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 ./b for local multi-module work. Relative use paths.
  • Prefer a workspace over a committed replace to a relative folder.
  • Commit go.work only when the repo is the workspace. Do not commit it into a library strangers import.
  • replace is for forks you publish, or a temporary patch you will delete.
  • go run ./desk from the workspace root. Do not copy source between modules by hand.

Try this

  1. In Case 1, add a soup price and print it. Do not create a second module.
  2. In Case 2, add prices.For("soup") returning 5. Run go run ./desk from work/ and from desk/ (with GOWORK=off from desk/ it should fail to find example.com/prices unless you added a real require+replace).
  3. Move go.work aside and try Case 3’s replace. Then delete the replace and restore go.work. Notice which file you would be willing to commit.