Go Modules and Packages: Setting Up and Structuring a Go Project
What a Go module actually is, how go.mod and go.sum work, how packages and import paths map to directories, and how to structure a project before you write your first real program.
TL;DR
A Go module is a versioned collection of packages defined by a go.mod file; a package is just a directory of .go files sharing one name, and the import path used to reach it is the module path plus that directory's path.
What You'll Learn
- What a Go module is, and what go mod init actually creates
- How go.mod and go.sum track your dependencies and their exact versions
- How a package maps to a directory, and how import paths are built from the module path
- The difference between package main and every other package
- What exported (Capitalized) vs unexported (lowercase) names mean across packages
- How to add, update, and clean up dependencies with go get and go mod tidy
- A sensible starting folder structure for a small Go program
The problem
Every Go tutorial starts with go run main.go, but the first time you need a
second file, a third-party package, or just want to know why import "github.com/you/project/internal/db" resolves to a specific folder on disk,
the mental model has to exist before any of it makes sense. This article
builds that model: modules, packages, and import paths, from the ground up.
What a module is
A module is a collection of Go packages that are versioned and released
together, declared by a single go.mod file at its root. Creating one is one
command:
mkdir myapp && cd myapp
go mod init github.com/yourname/myappThat produces a go.mod file:
module github.com/yourname/myapp
go 1.26module github.com/yourname/myapp— this is the module's path. It doesn't have to be a real, reachable URL for local development, but it needs to be a real one the moment you want someone else (or your own CI) togo getit, since Go fetches modules from exactly that path.go 1.26— the minimum Go version this module requires.
Packages: a directory, not a file
A package is a directory containing one or more .go files that all
start with the same package declaration:
// internal/store/user.go
package store
type User struct {
ID string
Name string
}// internal/store/save.go
package store
func Save(u User) error {
// ...
return nil
}Both files are part of the same store package because they declare
package store and live in the same directory — Go doesn't care how many
files a package spans, only that they agree on the package name and sit
together on disk.
Rendering diagram…
Import paths: module path + directory
The import path you write in an import statement is the module's path
plus the directory's path relative to the module root:
import "github.com/yourname/myapp/internal/store"That single line means: "find the module github.com/yourname/myapp, go
into its internal/store directory, and give me the package declared
there." This is why moving a package to a different folder means updating
every import of it — the path is the location.
package main is special
Every Go program needs exactly one package called main, containing a
func main() — that's the entry point go run and go build look for.
Every other package is a library: something imported by main (or by
another library, transitively reaching main), never run directly.
// cmd/api/main.go
package main
import (
"fmt"
"github.com/yourname/myapp/internal/store"
)
func main() {
u := store.User{ID: "1", Name: "Ada"}
fmt.Println(u)
}A common layout — used throughout this site's example projects — puts
main under cmd/<binary-name>/, so a module that produces multiple
binaries (an API server, a worker, a CLI) keeps each main package
separate: cmd/api/main.go, cmd/worker/main.go.
Exported vs. unexported: capitalization is the access modifier
Go has no public/private keywords. Visibility outside a package is
decided entirely by the first letter of the name:
package store
type User struct { // exported — visible to importers
Name string // exported field
id string // unexported — only visible inside package store
}
func Save(u User) {} // exported function
func validate(u User) bool { return u.Name != "" } // unexported helperCode in another package can reach store.User and store.Save, but has no
way to reference store.validate or read user.id at all — the compiler
rejects it. This is the actual mechanism behind Go's encapsulation, and it's
also why the internal/ directory convention exists: any package under a
path containing internal/ can only be imported by code inside the same
module tree rooted above that internal/ directory, giving you a
package-level privacy boundary, not just a name-level one.
Adding dependencies
Importing a package that isn't part of the standard library or your own
module, then running go mod tidy, is the entire workflow:
import "github.com/google/uuid"go mod tidygo mod tidy does two things: it adds any dependency your code imports but
go.mod doesn't list yet, and it removes any dependency go.mod lists that
nothing imports anymore. After it runs, two files are updated:
go.mod— records the module path, Go version, and each direct dependency's version (e.g.github.com/google/uuid v1.6.0).go.sum— records a cryptographic checksum of the exact bytes of every dependency (direct and transitive), so a secondgo build— on your machine, a teammate's, or CI — fetches and verifies the identical code, not just a version number that happens to match.
Both files should be committed to version control. Neither should be
hand-edited — let go get and go mod tidy manage them:
go get github.com/google/uuid@v1.6.0 # add or upgrade to a specific version
go get -u github.com/google/uuid # upgrade to the latest compatible version
go mod tidy # reconcile go.mod/go.sum with actual importsA starting project structure
For a small Go backend, this is enough structure to grow into without over-engineering on day one:
myapp/
├── go.mod
├── go.sum
├── cmd/
│ └── api/
│ └── main.go # package main — the entry point
└── internal/
├── handler/ # HTTP handlers
├── store/ # data access
└── model/ # shared typesinternal/ isn't required by the language for a single-binary project, but
using it from the start means you never have to retrofit the boundary later
if the project grows into something that ships more than one binary or gets
split into services.
Common mistakes
- One file per package, when a package needs more than one type or
function. Packages are directories — split by responsibility (
store,handler), not by forcing exactly one file each. - Hand-editing
go.mod/go.sum. Rungo get/go mod tidyinstead — hand edits routinely produce ago.sumthat doesn't match the actual dependency code, which fails the build with a checksum mismatch. - Forgetting
go mod tidyafter removing an import. The now-unused dependency lingers ingo.moduntil tidy is run again. - Expecting lowercase names to be reachable via reflection-like tricks from another package. They're genuinely inaccessible — that's the point.
Summary
A module is one go.mod file and everything under it; a package is a
directory of .go files sharing a package declaration; an import path is
just the module path plus that directory's relative path. package main is
the one package that can be run; everything else is a library. Capitalization
is Go's entire visibility system. Once those four ideas are solid, go.mod,
go.sum, and every import line in a real project stop being magic and
start being exactly what they say.
Key Takeaways
- go.mod names your module and pins every dependency to an exact version; go.sum pins the exact bytes of those versions for reproducible builds
- A package is a directory of .go files sharing the same package declaration — not one file per package
- Only package main can be run directly (go run/go build); every other package is a library imported by something else
- An identifier starting with a capital letter is exported (visible outside its package); lowercase is private to the package
- Run go mod tidy after changing imports to keep go.mod and go.sum accurate — don't hand-edit them
Go Fundamentals Series
Article 1 of 13
- 1.Go Modules and Packages: Setting Up and Structuring a Go Project
- 2.Slices, Arrays, and Maps in Go: How They Actually Work
- 3.Pointers in Go: When to Use *T and Why
- 4.Structs, Methods, and Composition in Go
- 5.Error Handling in Go: Custom Errors, Wrapping, and errors.Is/As
- 6.Defer, Panic, and Recover in Go
- 7.Go Interfaces: Design Small, Testable Components
- 8.Goroutines and Channels in Go: A Fresher-Friendly Guide
- 9.Understanding HTTP Servers in Go: From TCP Listen to Your First Handler
- 10.sync.WaitGroup, sync.Mutex, and the Race Detector in Go
- 11.Context in Go: Cancellation, Deadlines and Request Propagation
- 12.Building Background Workers in Go with Goroutines and Channels
- 13.Graceful Shutdown in Go HTTP Servers
funcRelated()[]Article
Understanding HTTP Servers in Go: From TCP Listen to Your First Handler
What actually happens when you call http.ListenAndServe: TCP listen and accept, the goroutine-per-connection model, the Handler interface, routing, and the timeouts a real server needs — with diagrams and a runnable example.
Go Microservices: A Practical Project Structure
A practical, minimal project structure for Go microservices: service boundaries, internal packages, configuration, migrations, and Docker — without over-engineering.
Defer, Panic, and Recover in Go
How defer actually schedules a call, what panic does to the call stack, why recover only works inside a deferred function, and the real-world pattern of recovering from panics in HTTP middleware — with diagrams.