Go workspaces

Go workspaces allow managing multiple modules from a single directory using a go.work file for unified builds and tests.

When local imports stop working

You are building a Go project that grew past a single folder. You split the code into a shared library and a command-line tool. Suddenly, importing the library from the tool stops working. The tool expects a published version on GitHub, but you are still writing the library locally. You start copying files, faking import paths, or wrestling with replace directives that break every time you run go mod tidy.

Concept in plain words

Go workspaces solve this by letting you treat multiple modules as one temporary project. Think of a workspace like a staging area in a warehouse. Instead of shipping every crate to the loading dock individually, you park them all in one yard, connect the conveyor belts, and test the whole flow before anything leaves the building. The go.work file is your yard map. It tells the Go toolchain exactly which local folders belong together and how they should talk to each other.

Workspaces were introduced in Go 1.18 to fix a specific pain point. Before that, local development across multiple modules required manual replace directives in every go.mod file. Those directives did not propagate automatically. You had to update them by hand, and they often conflicted with the actual dependency graph. A workspace centralizes that mapping. You declare the relationship once, and the compiler respects it everywhere.

Treat go.work as a local development shortcut. Never commit it to version control.

Minimal example

Create a root folder for your project. Inside, set up two modules: a library and a command that uses it.

mkdir -p workspace-demo/cmd workspace-demo/lib
cd workspace-demo
go work init
go work use ./cmd ./lib

The go work init command creates the go.work file. The go work use command registers the local directories. Now add the module definitions and the code.

// lib/go.mod
module example.com/workspace-demo/lib

go 1.21
// lib/lib.go
package lib

// Greet returns a formatted greeting string.
func Greet(name string) string {
    // The function returns a simple string to keep the example focused.
    return "Hello, " + name
}
// cmd/go.mod
module example.com/workspace-demo/cmd

go 1.21

require example.com/workspace-demo/lib v0.0.0
// cmd/main.go
package main

import (
    "fmt"
    "example.com/workspace-demo/lib"
)

// main demonstrates cross-module compilation within a workspace.
func main() {
    // The import path matches the module path, not a relative file path.
    fmt.Println(lib.Greet("developer"))
}

Run go run ./cmd from the workspace root. The output prints Hello, developer. The compiler never touched the internet. It resolved the import directly to the local ./lib folder because the workspace told it to.

Keep import paths stable. Workspaces map paths to folders, they do not rewrite your import statements.

Walk through what happens

When you invoke go run, go build, or go test inside a workspace, the toolchain switches from module mode to workspace mode. It reads go.work first. That file contains a list of directories and their corresponding module paths. The compiler builds a unified dependency graph that spans all registered modules.

If cmd imports lib, the compiler checks the workspace map. It finds the local path, loads the source files, and compiles them together. External dependencies like net/http or third-party packages still follow the normal module resolution rules. They are fetched from the module cache or the network. Only the modules explicitly listed in go.work get the local treatment.

The workspace does not merge your go.mod files. Each module keeps its own version requirements and dependency list. The workspace simply overlays a routing table on top of them. When you run go work sync, the toolchain walks through every module in the workspace and updates their go.mod files to match the resolved versions. This keeps your local dependencies consistent without manual editing.

Workspace mode is a view, not a merge. Your modules stay independent until you publish them.

Realistic example

Real projects rarely stay at two folders. You will likely have a web server, a CLI tool, and a shared configuration package. Here is how a workspace handles a realistic setup with version constraints and local overrides.

// pkg/config/config.go
package config

// Load reads environment variables and returns a configuration struct.
func Load() map[string]string {
    // Returning a map keeps the example simple while demonstrating package usage.
    return map[string]string{
        "port": "8080",
        "env":  "development",
    }
}
// cmd/server/main.go
package main

import (
    "fmt"
    "log"
    "net/http"
    "example.com/myproject/pkg/config"
)

// main starts an HTTP server using configuration from a local package.
func main() {
    // Load configuration before starting the server to catch missing values early.
    cfg := config.Load()

    // Register a simple handler that prints the configuration.
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Server running on port %s in %s mode", cfg["port"], cfg["env"])
    })

    // Start the server and block until an error occurs.
    log.Printf("Listening on :%s", cfg["port"])
    log.Fatal(http.ListenAndServe(":"+cfg["port"], nil))
}

In this setup, pkg/config might have its own dependencies, like a YAML parser or an environment variable library. The cmd/server module imports it. Without a workspace, you would need to publish pkg/config to a version control system, tag it, and update cmd/server to point to that tag. With a workspace, you edit pkg/config, run go run ./cmd/server, and see the changes immediately. The compiler treats the local folder as the source of truth.

You can also mix workspace modules with external dependencies. If pkg/config requires gopkg.in/yaml.v3, the workspace resolves that normally. The go.work file only intercepts imports that match the modules you registered. Everything else follows standard Go module semantics.

Use workspaces for local iteration. Publish modules when the API stabilizes.

Pitfalls and compiler behavior

Workspaces simplify local development, but they introduce a few friction points if you treat them like production artifacts.

The most common mistake is forgetting to register a module. If you create a new folder and import it without running go work use ./new-folder, the compiler rejects the build with cannot find module providing package example.com/myproject/new-folder. The workspace does not auto-discover directories. You must explicitly add them.

Another trap is mixing replace directives with workspace mode. If your go.mod files still contain replace example.com/myproject/lib => ../lib, the workspace ignores those lines. The go.work file takes precedence for modules it manages. Leaving old replace directives creates dead code that confuses other developers. Run go mod tidy after switching to a workspace to clean up unused directives.

Dependency drift happens when you update a third-party package in one module but forget to sync the others. Running go get inside a workspace only updates the current module's go.mod. The other modules keep their old versions. Run go work sync to propagate the new version across every module in the workspace. This command aligns the go.mod files without changing your source code.

Finally, do not commit go.work to your repository. Continuous integration systems should build each module independently. Workspaces are a local developer convenience. If you push the file, your CI pipeline will fail because it cannot resolve the local paths. Add go.work to your .gitignore.

Keep go.work local. Let CI build modules the way production will.

Convention asides

Go has a few unwritten rules around workspaces that save you time. The go.work file always lives in the root of your local development tree. It is not meant to be nested inside a module. The toolchain stops searching for it once it finds one, so keep your directory structure flat.

The use directive supports version constraints, but you rarely need them. Most developers use go work use ./path without a version tag. The workspace assumes you want the exact local copy. If you accidentally pin a version, the compiler complains with go.work: use directive version must be empty for local modules. Leave the version field blank for local paths.

Run gofmt on your go.work file just like any other source file. The toolchain does not format it automatically, but keeping it tidy prevents merge conflicts when multiple developers edit the workspace layout. Most editors run gofmt on save, so you can rely on that convention.

Trust the toolchain. Workspaces are designed to be ephemeral.

Decision matrix

Use a single module when your project is small enough to fit in one go.mod and you want the simplest possible build setup. Use a Go workspace when you are developing multiple local modules simultaneously and need them to import each other without publishing. Use replace directives when you only need to override a single dependency for one module and do not want workspace overhead. Use independent module builds in CI when your modules are meant to be published separately and you want strict version isolation.

Pick the structure that matches your release cadence. Workspaces bridge the gap between local edits and published versions.

Where to go next