Skip to content
GoBeginner6 min read

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.

GoBackend.dev
GoGo ModulesPackagesProject Structure

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/myapp

That produces a go.mod file:

module github.com/yourname/myapp
 
go 1.26
  • module 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) to go get it, 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 helper

Code 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 tidy

go 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 second go 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 imports

A 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 types

internal/ 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. Run go get/go mod tidy instead — hand edits routinely produce a go.sum that doesn't match the actual dependency code, which fails the build with a checksum mismatch.
  • Forgetting go mod tidy after removing an import. The now-unused dependency lingers in go.mod until 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