Skip to content
C Codeloom
Go

Go Packages and Modules with go.mod

A practical tour of Go packages and modules: go mod init, imports, internal packages, semantic versioning, and the replace directive for local development.

·5 min read · By Yash Kesharwani
Intermediate 10 min read

What you'll learn

  • How packages and modules differ in Go
  • How to bootstrap a module with go mod init
  • How to import internal packages and structure code
  • How semantic versioning and module paths interact
  • How to use the replace directive for local development

Prerequisites

  • A working Go toolchain from [Go install and first program](/blog/go-install-and-first-program)
  • Familiar with [Go functions](/blog/go-functions)

Every nontrivial Go project lives inside a module. Modules are how Go finds your code, resolves dependencies, and decides which versions to use. Once you understand how go.mod, import paths, and the module cache fit together, structuring a project becomes straightforward.

Packages vs modules

A package is a directory of .go files sharing the same package declaration at the top. A module is a collection of related packages versioned together, defined by a go.mod file at the module’s root. A repository typically contains one module, which contains many packages.

myapp/
  go.mod
  main.go              // package main
  internal/
    storage/
      storage.go       // package storage
  pkg/
    api/
      api.go           // package api

The directory determines the import path; the package line determines the name used in code.

Creating a module

go mod init writes a go.mod for a new project. The argument is the module path, which by convention is the location where the code will live, even if you do not push it there immediately.

mkdir myapp && cd myapp
go mod init github.com/you/myapp

This creates:

module github.com/you/myapp

go 1.22

The module path becomes the prefix for every package in the project. A file at internal/storage/storage.go would be imported as github.com/you/myapp/internal/storage.

A small example

A minimal two-package module:

// file: internal/greet/greet.go
package greet

import "fmt"

func Hello(name string) string {
	return fmt.Sprintf("hello, %s", name)
}
// file: main.go
package main

import (
	"fmt"

	"github.com/you/myapp/internal/greet"
)

func main() {
	fmt.Println(greet.Hello("Ada"))
}

Run it with go run .. The Go tool resolves the import via go.mod, finds the package on disk, and compiles everything together.

Exported names

Identifiers that start with an uppercase letter are exported and visible outside the package; lowercase identifiers are package-private. This is the only access control Go has, and it works at the package boundary rather than per type.

package greet

// Exported: callable from other packages
func Hello(name string) string { return formal(name) }

// Unexported: only visible inside this package
func formal(name string) string { return "hello, " + name }

The internal directory

A directory named internal is special. Packages inside it can be imported only by code rooted in the parent of that internal directory.

github.com/you/myapp/
  internal/storage/      // importable only by github.com/you/myapp/...
  pkg/api/               // importable by anyone who depends on the module

Use internal/ for implementation details you do not want to commit to as a public API. It is the most effective way in Go to keep your public surface small.

Adding dependencies

go get adds a dependency and updates go.mod and go.sum:

go get github.com/google/uuid

In code:

import "github.com/google/uuid"

func newID() string {
	return uuid.NewString()
}

go.mod gains a require line; go.sum records cryptographic hashes used to verify future downloads. Both files should be committed.

go mod tidy synchronizes go.mod with what your code actually uses, removing unused entries and adding missing ones. Run it before commits.

Semantic import versioning

Go modules use semantic versioning. A tag like v1.4.2 is a release; v0.x.y is treated as unstable. For v2 and beyond, Go enforces a different rule: the major version must appear in the import path.

import "github.com/you/lib/v2/sub"

This lets a single project depend on both v1 and v2 of the same module without a conflict. When you tag v2.0.0, you also update your module path in go.mod:

module github.com/you/lib/v2

It is the most surprising rule for newcomers, but it is what makes long-lived ecosystems possible.

The replace directive

While developing, you often need to point at a local checkout of a dependency, or temporarily redirect to a fork. The replace directive does this without changing import paths in code.

// go.mod
module github.com/you/myapp

go 1.22

require github.com/you/lib v1.3.0

replace github.com/you/lib => ../lib

Now import "github.com/you/lib" resolves to ../lib on disk. Use replace for local workflows and emergencies; do not publish a module that relies on replace to build, because consumers cannot inherit those redirects.

For multi-module workspaces, go work init and go.work are usually a cleaner alternative.

Build and run commands

A few commands you will use constantly:

  • go run . compiles and runs the current package.
  • go build ./... builds every package in the module.
  • go test ./... runs every test in the module.
  • go install ./cmd/myapp installs a binary under $GOBIN.
  • go mod tidy cleans up go.mod/go.sum.

The ./... pattern means “this directory and everything below,” which is the usual scope for module-wide operations.

A pragmatic layout

For an application with multiple binaries:

myapp/
  go.mod
  cmd/
    api/main.go        // binary: myapp-api
    worker/main.go     // binary: myapp-worker
  internal/
    config/
    storage/
    http/
  pkg/
    sdk/               // optional public Go SDK

This matches what most production Go services look like. It also keeps the import graph easy to reason about: high-level binaries depend on internal/ packages; internal packages do not depend on cmd/.

For data-shaping packages that show up in nearly every project, the patterns in Go slices and maps and the branching styles in Go control flow appear over and over.

Wrap up

Modules are how Go organizes code at scale: one go.mod per repository, packages laid out as directories, and import paths that mirror your VCS location. Use internal/ to enforce boundaries, semantic versioning to communicate change, and replace (sparingly) for local development. With these pieces in place, you can grow a single-file script into a multi-binary service without reorganizing your imports.