Module vs Package vs Package Path

Go has a confusing terminology around modules, packages, and package paths. This post aim to provide you simple but sufficient mental model to go modules.

Module

Russ Cox define go module as a collection of packages sharing a common import path prefix, known as the module path. A more intuitive mental model would be describe as a collection of Go packages that are versioned together as a single unit. It’s defined by a go.mod file at its root:

module github.com/user/myproject
 
go 1.21

Think of a module as a “project” - it can contain multiple packages, but they all share the same versioning lifecycle. When you run go mod init, you’re creating a module.

Package

A package is a directory containing Go source files that all belong to the same namespace (we also call this package name). Every .go file in the same directory must declare the same package name:

// In file: myproject/auth/login.go
package auth
 
// In file: myproject/auth/token.go
package auth  // Must be the same package name

The package name (what comes after package) is what you use in your code to reference exported identifiers from that package.

Package Path

The package path is the import path used to import a package. It’s defined as:

module name + "/" + [the directory path relative to the module root]

A concrete example is shown below:

Module:        github.com/user/myproject
Directory:     auth/
Package Path:  github.com/user/myproject/auth

The Confusing Part

Here’s where it gets tricky - the package name and the last element of the package path don’t have to match:

// Directory structure:
// project/
//   go.mod (module github.com/user/project)
//   authentication/
//     login.go
 
// In login.go:
package auth  // Package name is "auth"
 
// To import:
import "github.com/user/myproject/authentication"  // Package path
 
// To use:
auth.Login()  // Use package name, not "authentication"

That’s because compiler use package path to find the package, but the developer use package name (define by package <name> in each go file) in the actual source code.

Important

While you can have different package names and directory names, it’s confusing and considered bad practice. Stick to matching them unless you have a very good reason.

Main Package Special Case

The main package is special - it’s the entry point for executable programs:

// In any directory, usually cmd/ or root
package main
 
func main() {
    // This is where your program starts
}

You can have multiple main packages in different directories within the same module, each creating a separate executable.

Module Versioning & Import Compatibility

Go’s module system follows Russ Cox’s import compatibility rule, which is the foundation of everything that makes Go modules work without the complexity hell of other package managers.

image

The Import Compatibility Rule

If an old package and a new package have the same import path, the new package must be backwards-compatible with the old package.

This is THE rule that saves you from dependency hell. Here’s why this matters:

// If your code works with v1.2.3
import "github.com/some/package"
 
// It MUST work with v1.9.5 (same import path)
import "github.com/some/package"  // Still works!
 
// But v2.0.0 gets a different import path
import "github.com/some/package/v2"  // Breaking changes allowed

This means updating from v1.2.3 → v1.9.5 should never break your build. If it does, that’s a bug in the package author’s v1.9.5, not expected behavior.

Minimal Version Selection

Unlike most package managers that use the “newest allowed version” (which changes when new releases come out), Go uses minimal version selection - it picks the oldest version that satisfies all requirements.

Your module requires: [email protected]
Some dependency requires: [email protected]
Result: Go picks v1.5.0 (the minimum that satisfies both)

This gives you:

  • Reproducible builds: Same result today and tomorrow
  • No surprise breakage: Won’t suddenly use newer versions
  • Simple conflict resolution: Just pick the highest minimum requirement

Major Version Handling

When package authors need to make breaking changes, they bump the major version and change the import path:

// v1.x.x - Original API
import "github.com/yaml/yaml"
 
// v2.x.x - Breaking changes, new import path
import "github.com/yaml/yaml/v2"
 
// You can use both in the same program!
func convert() {
    oldData := yaml.Marshal(data)      // v1 API
    newData := yamlv2.Marshal(data)    // v2 API
}

This lets different parts of a large program migrate from v1 → v2 independently, avoiding the “diamond dependency problem” that plagues other ecosystems.

Why This Works

Traditional package managers try to solve incompatibility with complex constraint solving. Go eliminates incompatibility instead:

  • No conflicting constraints: If two packages both import foo, they get the same foo (unless they explicitly import different major versions)
  • No “dependency hell”: There’s always exactly one valid solution
  • No lock files needed: The minimal version selection algorithm is deterministic

More Readings

Semantic Import Versioning go.sum Is Not a Lockfile