How to Organize a Monorepo with Go Workspaces

Organize a Go monorepo by creating a go.work file and adding modules with go work use to enable cross-module development.

The local import problem

You start a Go project with a single main.go and one go.mod. It works fine. Six months later, you have a CLI tool, a shared HTTP client, and a database migration package. You split them into separate modules to keep dependencies clean. Now you face a new problem. Every time you change a function in the client package, you have to update the CLI module's go.mod with a replace directive pointing to the local path. Switching Git branches breaks the build because the local paths drift. Running tests requires jumping between directories or writing a Makefile that calls go test ./... three times. The toolchain fights you because it thinks you are building three independent projects instead of one cohesive codebase.

How workspaces fix it

Go workspaces solve this by giving the compiler a single source of truth for local development. A workspace tells the Go toolchain to treat multiple modules as one unit. You create a go.work file at the repository root. It lists every module directory you want to include. When you run go build, go test, or go vet, the toolchain reads the workspace file first. It resolves imports across module boundaries without touching go.mod files. You stop managing replace directives for local development. The workspace file lives in .gitignore. It is a developer convenience, not a deployment artifact. Go introduced workspaces in version 1.18 to replace the fragile replace directive pattern that developers used for years. The old pattern required manual path tracking and constant go mod tidy runs. Workspaces automate the override logic. Treat the workspace as your local development environment and the go.mod files as your published contract. Keep your local overrides local.

The minimal setup

Here is the simplest workspace setup. You have two modules in the same repository. One provides a utility function. The other imports it.

// cmd/app/main.go
package main

import (
    "fmt"
    "example.com/myproject/pkg/utils"
)

// Run the main entry point
func main() {
    // Call the shared utility
    result := utils.Calculate(5)
    fmt.Println(result)
}
// pkg/utils/math.go
package utils

// Calculate returns a doubled integer
func Calculate(n int) int {
    return n * 2
}

Each directory has its own go.mod. Without a workspace, cmd/app cannot find pkg/utils unless you add a replace directive. With a workspace, you run two commands from the repository root.

# Initialize the workspace file with the current Go version
go work init

# Register both module directories as workspace members
go work use ./cmd ./pkg

The go.work file now contains a use block pointing to both paths. You can run go run ./cmd/app from anywhere in the tree. The compiler resolves the import automatically. Keep your workspace file out of version control. Let each developer generate their own local configuration.

What happens under the hood

When you invoke a Go command inside a workspace, the toolchain switches modes. It reads go.work before looking at any go.mod file. The use directives tell the compiler which directories contain module roots. When cmd/app imports example.com/myproject/pkg/utils, the toolchain checks the workspace first. It finds the local pkg/utils directory and treats it as the canonical source. It ignores the version specified in go.mod for that import. This override only applies to local development. The go.mod files remain untouched. When you publish a module or run CI, the workspace file is ignored. The standard dependency resolution takes over.

The workspace also handles version alignment. If cmd/app requires golang.org/x/text v0.3.0 and pkg/utils requires v0.5.0, the workspace upgrades both modules to v0.5.0 automatically. You do not need to manually sync dependency versions across multiple go.mod files. The toolchain enforces a single version of every third-party dependency across the entire workspace. This prevents the diamond dependency problem where two modules pull conflicting versions of the same library. The go.sum file in each module directory stays synchronized with the workspace state. You can verify the active workspace by running go env GOWORK. It prints the absolute path to your go.work file. Trust the toolchain to manage the graph. Focus on writing code.

A realistic three-module layout

Real projects grow beyond two directories. You might have a web server, a background worker, and shared configuration logic. Here is how a workspace handles a three-module layout with a shared dependency.

// internal/config/loader.go
package config

// Load reads environment variables and returns a typed struct
func Load() map[string]string {
    return map[string]string{
        "DB_HOST": "localhost",
        "PORT":    "8080",
    }
}
// cmd/server/main.go
package main

import (
    "fmt"
    "example.com/myproject/internal/config"
)

// Start the HTTP server
func main() {
    // Pull configuration from the shared internal package
    settings := config.Load()
    fmt.Println("Starting on", settings["PORT"])
}

You register all three directories in the workspace. The internal directory follows Go's visibility rules. Only modules inside example.com/myproject can import it. The workspace respects this boundary. You run go work sync to align all go.mod files with the workspace's dependency graph.

# Register the new internal module
go work use ./internal

# Update all go.mod files to match workspace dependency versions
go work sync

The sync command is the key to keeping things clean. It writes the unified dependency versions back into each module's go.mod. It also removes outdated replace directives that point to local paths. You run it after adding a new module or after pulling changes from a teammate. The command behaves like go mod tidy, but it operates across the entire workspace instead of a single directory. Run sync before committing. Keep the module files in sync with the workspace state.

Common traps and how to avoid them

Workspaces are straightforward, but a few patterns trip people up. The most common mistake is mixing replace directives with workspace usage. If you keep replace lines in your go.mod files while using a workspace, the toolchain gets confused about which local path to trust. The compiler rejects the build with go.mod requires Go 1.21 but workspace requires Go 1.22 when version constraints clash, or it throws a module ... is not in the workspace error when an import points outside the registered directories. Run go work sync to clear out stale replace directives. Let the workspace handle local overrides.

Another trap is IDE configuration. Many editors default to single-module mode. They will report false errors because they cannot see across module boundaries. You must tell your editor to use the workspace. In VS Code, set "go.useLanguageServer": true and ensure the language server picks up the go.work file. In GoLand, open the repository root as the project. The editor will automatically detect the workspace and resolve cross-module imports. Forgetting to commit the go.mod files after running sync breaks CI. The workspace file stays in .gitignore, but the updated go.mod files must be committed. Continuous integration runs without a workspace. It relies on the synchronized go.mod files to build correctly. If you skip the commit step, the pipeline fails with a missing dependency error. Vendor directories also cause friction. Workspaces do not support go mod vendor across multiple modules. You must vendor each module individually or skip vendoring entirely. Stick to the module cache for local development. Avoid fighting the toolchain with manual directory manipulation.

Go conventions matter here too. Module paths should match your repository URL. Use example.com/myproject instead of github.com/username/myproject if you plan to mirror the code. The gofmt tool runs identically across all workspace members. Let it format your code automatically. Do not argue about indentation. The community accepts consistent formatting so you can focus on logic. Run gofmt -w . from the workspace root to format everything at once. Keep your style uniform.

When to use workspaces

Use a single go.mod when your project is small and all packages share the same dependencies. Use a workspace when you split a repository into multiple modules for local development but want a unified build experience. Use separate repositories when teams deploy independently and maintain different release cycles. Use replace directives only when you need to patch a third-party module temporarily and cannot use a workspace. Keep your development environment simple. Let the toolchain do the heavy lifting.

Where to go next