Back to blog

Sunday, October 25, 2026

Why Are Nested Go Modules Bad?

Title card for 'Why Are Nested Go Modules Bad?' with a short code example

Introduction

Nested Go modules are bad, most of the time, because a subdirectory with its own go.mod file stops being part of the parent module. It becomes a separate module, with its own dependency list, its own version, and its own build scope. The parent build then skips it, the parent import path no longer reaches it, and every tool that worked on the whole tree now needs to be run twice. This article explains what a nested module actually is, why the parent skips it, the two honest fixes, and the narrow cases where a separate module is the right call.

Before You Start

  • Module: a collection of Go packages stored in a file tree, with a go.mod file at its root. The go.mod file declares the module path, the Go version, and the other modules it requires.
  • Main module: the module containing the directory where the go command is run. The go command finds the module root by looking for a go.mod file in the current directory, then in each parent directory, until it finds one.
  • Package: a directory of Go source files in one module. An import path is the module path plus the directory path inside that module.
  • Nested module: a subdirectory inside a module that has its own go.mod file. That subdirectory is the root of a different module, and its packages belong to that module, not to the parent.
  • Require: a go.mod directive that names another module and the version to use.
  • Replace: a go.mod directive that tells the go command to use a local directory, or a different version, instead of the required version. A replace directive applies only in the main module. It is ignored when the module that declares it is used as a dependency by someone else.
  • Workspace: a go.work file that lists several local modules, so the go command can resolve them together during local development, without a replace directive in every go.mod.

Prerequisites

  • Go 1.20 or later, because the article uses module and workspace behaviour that is stable in that version.
  • A terminal, and a text editor.
  • No third party packages are required for the complete example.
go version

Requires: Go 1.20 or later. Verified on 2026-10-25 against the official sources in References.

Project Layout

The complete example starts as the broken layout, then becomes the fixed layout. The fixed layout is the one to copy.

nested-bad/
├── go.mod                 # parent module, example.com/shop
├── main.go                # main package, imports the pricing package
└── pricing/
    ├── pricing.go         # pricing package
    └── pricing_test.go    # test for the pricing package
  • go.mod: declares the parent module and the minimum Go version.
  • main.go: the main package, which uses the pricing package.
  • pricing/pricing.go: a plain package inside the parent module, in the fixed layout.
  • pricing/pricing_test.go: a test for that package, run by the parent module.

In the broken layout, there is one extra file: pricing/go.mod, which declares a second module. That single file is the whole problem, and the article returns to it in every section.

What A Nested Module Actually Is

A module boundary is a go.mod file. Every directory under a module root belongs to that module, except a directory that contains its own go.mod file. That directory, and everything under it that is not itself another module root, belongs to the new module instead.

That rule has three consequences that are missed often when the nested go.mod is created by accident, for example by running go mod init inside a subdirectory, or by copying a whole repository into a subdirectory:

  • The nested packages are not parent packages: an import path that starts with the parent module path does not reach them, because they belong to a different module path.
  • The parent build skips them: go build ./... and go test ./... run from the parent root cover the parent module only. They do not build or test packages in a nested module.
  • The nested module has its own dependency list: its go.mod and go.sum are separate. A dependency added for the nested code does not appear in the parent go.mod, and a dependency removed from the parent does not affect the nested module.

None of this is a bug. It is the module system doing exactly what a go.mod file means. The pain comes from expecting the nested directory to behave like a normal package directory, when the file system now says otherwise.

Why The Parent Import Fails

Take the broken layout, with a parent module example.com/shop and a nested module in pricing/:

nested-bad/
├── go.mod             # module example.com/shop
├── main.go            # imports "example.com/shop/pricing"
└── pricing/
    ├── go.mod         # module example.com/shop/pricing
    └── pricing.go

The main package imports example.com/shop/pricing, because that is the path a normal package directory would have. But the pricing directory is not a package of the parent module. It is the root of a module whose path is example.com/shop/pricing, and the parent module does not require that module. The build therefore fails, because no module in the parent build provides that import path.

The error is confusing because the file is right there, on disk, in the right folder. The file system says the code exists. The module system says it belongs to a different module, and the parent build does not look inside other modules for packages, except through a require directive. A developer who reads the error as a missing file, or a typo in the import path, can spend a long time fixing the wrong thing.

The same rule explains a second confusing failure. A developer runs go mod tidy in the parent, expecting it to fix the import, and it does not add the nested module, because go mod tidy in the parent only tidies the parent module. It does not cross a module boundary. The tidy that matters for the nested code is the tidy run inside the nested directory, where that module is the main module.

Why Replace Does Not Save The Nested Layout For Everyone

One fix for the broken layout is to keep the nested module and require it from the parent, with a replace directive pointing at the local directory:

module example.com/shop

go 1.20

require example.com/shop/pricing v0.0.0

replace example.com/shop/pricing => ./pricing

The main package then imports example.com/shop/pricing, which is now the nested module path, and the replace directive tells the parent build to use the local directory instead of fetching a version. Locally, this works. It is also the pattern used by real multi module repositories, where a subcommand or an example module replaces the root module with a local path.

The problem is scope. A replace directive applies only in the main module. When someone else uses example.com/shop as a dependency, that consumer's build ignores the replace directive in the parent go.mod. The consumer will try to fetch example.com/shop/pricing at the required version, and that version must exist, published, at that path. If the nested module was never published, or was published under a different tag, the consumer build fails, even though the author build worked.

Publishing a nested module has its own rule, which is another place the layout bites. A version tag for a module in a subdirectory carries the subdirectory as a prefix, for example pricing/v1.0.0, not a plain v1.0.0 at the repository root. A plain root tag versions the root module only. A developer who tags v1.0.0 and expects the nested module to be versioned with it has versioned the wrong module, or no module at all for the nested code.

A workspace, with a go.work file listing both modules, solves the local development part without a replace directive in either go.mod. It does not solve the consumer part either. A workspace is a local development tool. A consumer who imports the parent module does not get the workspace, and still needs a published version of the nested module.

When A Separate Module Is Actually Right

Nested modules are not always wrong. They are wrong when the code is only used by the parent, and a plain package would do. They are right when the code has a genuinely separate life, and that separation is worth the cost. The cases that justify it are narrow, and they should be named before the layout is chosen:

  • Independent versioning: the code is a library that must be released on its own schedule, with its own versions, while the parent keeps moving. A plain package cannot be versioned separately from its module.
  • Independent dependencies: the code needs a heavy or conflicting dependency set that should not pollute the parent. A separate module keeps that dependency list out of the parent go.mod, so consumers of the parent do not inherit it.
  • Separate publication: the code is meant to be imported by other repositories, under its own module path, while living in the same repository for development. A plain package in the parent can only be imported as part of the parent module.
  • Tool isolation: the code is a tool, an example, or a benchmark with its own dependencies, like the internal example and third party modules in large repositories. Keeping it separate stops tool dependencies from becoming parent dependencies.

If none of those apply, the layout is paying the multi module cost for no benefit. A package directory, without a go.mod file, gives the same code organisation, with one dependency list, one version, one test run, and one import path rule.

Design Decisions And Tradeoffs

OptionUse whenCost
Plain package in the parentThe code is only used by this app or moduleNo separate versioning, and the parent inherits its dependencies
Nested module with require and replaceThe code is a separate library, developed in the same repositoryReplace applies only in the main module, and consumers need a published version
Workspace with go.workSeveral local modules are developed side by sideWorkspace is local only, and must not be relied on by consumers or releases
Separate repositoryThe code is shared across repositories with its own release cycleA second repository, a second CI setup, and a second version history
Internal package in the parentThe code must not be imported outside the parentImport restriction is by directory, not by module, and it still shares the parent dependency list

A design in this article has a deliberate compromise, stated openly. The complete example uses a single pricing function, not a real library with dependencies, so the module boundary is the only thing being demonstrated. In a real library, the dependency isolation case would matter more, and the decision would be closer. The boundary rule itself is not compromised: a go.mod file always creates a new module, whether the code inside it is one function or a thousand.

Complete Example

The fixed layout is a single module. The pricing directory has no go.mod file. That absence is the fix, and it is worth stating plainly, because it looks like doing nothing.

// go.mod
module example.com/shop

go 1.20
// main.go
package main

import (
    "fmt"

    "example.com/shop/pricing"
)

func main() {
    fmt.Printf("discounted price: %.2f\n", pricing.Discounted(100, 0.10))
}
// pricing/pricing.go
package pricing

func Discounted(price float64, rate float64) float64 {
    return price * (1 - rate)
}

Commands to run it, from the nested-bad folder:

go build ./...
go run .

Expected output:

discounted price: 90.00

Why this works, in order:

  • The pricing directory has no go.mod, so it is a package of the parent module, with the import path example.com/shop/pricing.
  • go build ./... from the parent root includes the pricing package, because it belongs to the parent module.
  • The main package imports that path, and the parent module provides it, so no require or replace directive is needed.

To reproduce the broken layout, add one file, pricing/go.mod, with the content module example.com/shop/pricing and a go 1.20 line, and run go build ./... from the parent root again. The build fails, because the pricing package no longer belongs to the parent module, and the parent does not require the nested module. Removing that file restores the fixed layout. That single file, added and removed, is the whole demonstration.

If the nested module must be kept, the fix is different, and it must be complete. The parent go.mod needs a require and a replace, the main package imports the nested module path, and the nested module needs its own go.mod (and a go.sum once it has dependencies to checksum), tidied from inside the nested directory:

module example.com/shop

go 1.20

require example.com/shop/pricing v0.0.0

replace example.com/shop/pricing => ./pricing

In that kept layout, two more rules apply. First, run go mod tidy in both directories, because each module has its own dependency list. Second, publish the nested module with a prefixed tag, such as pricing/v1.0.0, before any consumer can use it, because a replace directive in the parent does not reach consumers.

Testing Modules

Test a module layout by running the tools from the right directory, not by assuming one run covers the whole tree. A parent test run does not cover a nested module, and a nested test run does not cover the parent.

// pricing/pricing_test.go
package pricing

import "testing"

func TestDiscounted(t *testing.T) {
    got := Discounted(100, 0.10)
    want := 90.0
    if got != want {
        t.Errorf("Discounted(100, 0.10) = %v, want %v", got, want)
    }
}

Commands to run the tests and the static check, from the nested-bad folder, in the fixed single module layout:

go vet ./...
go test ./...

Expected output for the tests is a pass, because the pricing package belongs to the parent module, so the parent test run includes it. In the broken nested layout, the same commands from the parent root would not run this test at all, because the pricing package belongs to a different module. The test would only run from inside the pricing directory, where the nested module is the main module.

A layout test that is worth adding to continuous integration is a check for unexpected go.mod files. List every go.mod in the repository, and confirm each one is intentional. An accidental nested go.mod, from a copied directory or a stray go mod init, is then caught before it changes the build scope.

Common Mistakes

  • Running go mod init in a subdirectory by accident: the subdirectory becomes a nested module, and the parent build skips it. The fix is to delete the nested go.mod, unless a separate module was intended.
  • Importing the parent path for nested code: a main package that imports example.com/shop/pricing fails when pricing is a nested module, because that path belongs to the parent module, not to the nested one. The fix is to make pricing a plain package, or to require and replace the nested module path.
  • Expecting go build ./... to cover nested modules: a parent build covers the parent module only. The fix is to run the build and tests in each module, or to remove the nested boundary.
  • Expecting go mod tidy in the parent to fix a nested module: tidy only tidies the main module it runs in. The fix is to run go mod tidy in each module directory.
  • Relying on a replace directive for consumers: a replace directive applies only in the main module, and is ignored when the module is used as a dependency. The fix is to publish the nested module at the required version, with a prefixed tag, before consumers can use it.
  • Tagging only the repository root: a plain v1.0.0 tag versions the root module, not a nested module. The fix is a prefixed tag, such as pricing/v1.0.0, for the nested module.
  • Committing a go.work file and expecting releases to use it: a workspace is for local development. A release build should run with the workspace off, or without the file, so the build uses the published module versions.
  • Keeping a nested module for code only the parent uses: the layout pays for separate versioning, separate dependency lists, and separate test runs, for no benefit. The fix is a plain package directory, with no go.mod file.
  • Copying a whole repository into a subdirectory: the copied repository brings its own go.mod, and becomes a nested module, even if the intent was to copy code. The fix is to copy the packages, not the module file, or to keep the copied code as a separate module on purpose.
  • Assuming a nested module isolates a bad dependency: isolation works for the dependency list, but the nested code still needs to be built, tested, versioned, and published separately. The fix is to weigh that full cost, not only the dependency list, before choosing the layout.

Production Notes

  • Continuous integration: run go build ./... and go test ./... in every module in the repository, not only at the root. A root only run leaves nested modules untested, and a green root run can hide a broken nested module.
  • Versioning: publish each module with its own tag, prefixed by its subdirectory for nested modules. A release process that tags only the root will not release nested modules, and consumers will fail to fetch the version the parent requires.
  • Dependency hygiene: run go mod tidy in each module, and review each go.mod separately. A dependency that looks missing from the parent may belong to a nested module, and a dependency that looks unused in the parent may be used only by a nested module.
  • Releases: do not rely on a local workspace or a local replace directive in a release build. A release should build from published module versions, with the workspace off, so the released artifact matches what a consumer will build.
  • Onboarding: document every module in the repository, with its path, its purpose, and why it is separate. A new developer who sees a subdirectory with a go.mod file should be able to tell, from the documentation, whether that file is intentional or an accident.

Decision Guide

  • When the code is only used by this app or module: use a plain package directory, with no nested go.mod file.
  • When the code must be versioned and published separately: use a nested module, with a require and replace for local development, and a prefixed tag for publication.
  • When the code needs a separate dependency set: use a nested module, and accept the separate tidy, test, and versioning cost that comes with it.
  • When several local modules are developed together: use a workspace for local development, and do not rely on it for consumers or releases.
  • When the code is shared across repositories: consider a separate repository, because a nested module in one repository still needs its own versioning and publication story.
  • When a nested go.mod appears unexpectedly: delete it, unless a separate module was intended and documented. An accidental module boundary is a bug in the layout, not a feature.

Conclusion

A nested Go module is not a package with extra organisation. It is a separate module, and that one fact changes the import path, the build scope, the test scope, the dependency list, and the version tag. Most nested modules in application code are accidents, created by a stray go mod init or a copied directory, and the fix is to delete the nested go.mod file.

Summary Of Key Points

  • A go.mod file is a module boundary: a subdirectory with its own go.mod belongs to a different module, not to the parent.
  • Parent builds skip nested modules: go build ./... and go test ./... from the parent root cover the parent module only.
  • Parent imports do not reach nested code: an import path under the parent module path fails when the directory is a nested module.
  • Replace is main module only: a replace directive does not reach consumers, so a nested module must be published for consumers to use it.
  • Nested tags carry a prefix: a nested module is versioned with a tag such as pricing/v1.0.0, not a plain root tag.
  • Workspaces are local only: a go.work file helps local development, and does not fix the consumer or release story.
  • Default to a plain package: if the code is only used by the parent, a package directory, with no go.mod, is the simpler and safer layout.

Follow those rules, and a module boundary becomes a deliberate choice, not a surprise: one module for one app, a separate module only when independent versioning or dependencies justify it, and no accidental go.mod file quietly cutting a package out of the build.

Additional Resources

  • Go Documentation: Go Modules Reference - module paths, main module, require, replace, and versioning rules
  • Go Documentation: Managing Dependencies - how modules, dependencies, and tidy work
  • Go Documentation: Workspaces - developing several local modules together with go.work
  • Go Packages: modload package - the main module and build list, and why replace and exclude in dependency modules are ignored

References

  1. Go Modules Reference. Go team, Go Documentation. https://go.dev/ref/mod. Accessed 2026-10-25.
  2. Managing Dependencies. Go team, Go Documentation. https://go.dev/doc/modules/managing-dependencies. Accessed 2026-10-25.
  3. Workspaces. Go team, Go Documentation. https://go.dev/doc/tutorial/workspaces. Accessed 2026-10-25.
  4. modload package. Go team, Go Packages. https://pkg.go.dev/cmd/go/internal/modload. Accessed 2026-10-25.