Understanding Go Packages and Modules: The Two-Layer System
Discover why Go separates packages from modules, and how this design enables scalable, reproducible builds
Part 3 of 3
This article
11 min read
Series total
18 min
Remaining
6 min
Progress
100%
I remember the moment it clicked.
I created a main.go file, wrote some code, then tried to import a local package I'd created in an auth/ folder. Simple enough, right?
import "auth"
Nope. Error: "import not found."
So I created a go.mod file, defined my module, and suddenly... it worked.
But why? Why couldn't I just import a local package directly? Why did I need this invisible "module" thing first?
The answer isn't in any quick tutorial. It's in understanding what packages and modules actually are, and why Go designed them as two separate concepts.
Let's uncover that together.
Part 1: What is a Package?
Let's start with the simpler concept: packages.
In Go, a package is a collection of Go files in a single folder that declare they belong together. Every .go file must start with a package declaration. That declaration tells Go: "These files are part of this package."
Think of a package like a folder of related functions in a library. If you had a library with authentication code, you'd put it in an auth folder. In Go, all files in that folder would declare package auth.
Here's how this looks in my project:
// In auth/auth.go
package auth
func Auth(name string) string {
var username string
username = name
username = "Username: " + username + "!"
return username
}
That Auth function lives in the auth package. It's in the auth/ folder, and the file declares package auth at the top.
The Entry Point Exception: The main Package
There's one special package in Go: main.
If you're building an application (not a library), you create a main package, and inside it, you must have a main() function. That function is your entry pointβit runs when someone executes your program.
// In main.go
package main
import "fmt"
func main() {
fmt.Println("This code runs when the program starts")
}
This is why I couldn't just create auth/auth.go and hope it would run. Go needs a main package with a main() function as the entry point. Without it, you have a library, not an application.
The Capitalization Rule: Making Things Public
Here's something that surprised me when I first saw it: Go doesn't use keywords like public, private, or export. Instead, it uses a naming convention.
If a function or variable name starts with an uppercase letter, it's exported (public). If it starts with lowercase, it's unexported (private).
In my auth package:
func Auth(name string) string { // Exported - starts with capital 'A'
var greeting string // Unexported - starts with lowercase 'g'
greeting = "Welcome," + name + "!"
return greeting
}
The Auth function is capitalized, so it can be imported and used by other packages. The greeting variable is lowercase, so it stays internal to the auth package.
Why does Go do this? Explicitness. You don't have to dig into documentation to know what's meant to be public. You can see it in the code. If a function name is capitalized, it's part of the package's contract.
This might seem unusual at first, but it's one of Go's design choices that makes code easier to read at scale.
Packages Enable Code Organization
Packages let you organize code into logical units. As your project grows, you don't put everything in one giant file. Instead, you create packages for related functionality:
authpackage - Authentication logicdatabasepackage - Database operationshandlerspackage - HTTP request handlersmodelspackage - Data structures
This isn't just about organization. Packages create boundaries. Each package has a responsibility. Other packages can use what's exported, but internal details stay hidden.
This matters at scale. When you have hundreds of thousands of lines of code across many developers, clear boundaries prevent chaos.
Part 2: What is a Module?
Now we get to the confusing part. We've learned about packages. So why do we need modules?
A module is a dependency management layer that sits above packages. It's Go's way of managing which code your project depends onβboth internal code (your own packages) and external code (third-party libraries).
Here's the key insight: Packages organize code. Modules manage dependencies.
The go.mod File
When you start a Go project, you create a go.mod file. This file is your project's dependency manifest.
module swn
go 1.21
This simple file tells Go:
- The name of your module is
swn - This project uses Go version 1.21 or later
That's it. But this tiny file unlocks everything.
Why Modules Exist: The Chicken-and-Egg Problem
Here's where it gets interesting. Remember when I tried to import my auth package and got an error?
import "auth" // β Error: import not found
I thought this should work. The auth package is right there in the auth/ folder!
But here's the problem: How does Go know where to find the auth package? Without additional context, it doesn't. Am I trying to import:
- A local package in my project?
- A package from GitHub?
- A package from some other location?
This is where modules solve the problem. When I define a module called swn, I'm saying: "All packages that start with swn/ are part of my project."
So the correct import becomes:
import "swn/auth" // β This works!
Now Go knows: "Oh, you want the auth package from the swn module. That's a local package in this project."
Modules Manage All Dependencies
Modules don't just solve the local-package problem. They also manage external dependencies.
When you download a third-party package (like a database driver), you run:
go get github.com/lib/pq
This command:
- Downloads the package
- Adds it to your
go.modfile - Lets you import it in your code
import "github.com/lib/pq"
The module system tracks what version of the package you're using, ensuring reproducibility. Everyone who clones your project and runs go get gets the same versions you're using.
The Coupling: Packages Need Modules
This is the core insight that confused me:
You can't import a local package without a module.
This isn't a limitation. It's a design choice. Go requires you to:
- Define a module (
go.mod) - Give it a name (
module swn) - Then reference packages by that module name (
import "swn/auth")
Why? Consistency. Whether you're importing a local package or a GitHub package, the syntax is the same. Both are referenced by their module path.
import "swn/auth" // Local package in my module
import "github.com/lib/pq" // External package from GitHub
This uniformity matters at scale. You don't have special rules for local vs. external imports. It's all one system.
Part 3: How They Work Together
Let's pull this all together by walking through my exact project.
The Structure
I have:
- A
go.modfile that says:module swn - A
main.gofile in the root (themainpackage, entry point) - An
auth/folder withauth.go(theauthpackage)
swn/
βββ go.mod # Declares: module swn, go 1.21
βββ main.go # package main, entry point
βββ auth/
βββ auth.go # package auth
How the Import Path Works
In my main.go, I import my auth package like this:
package main
import (
"fmt"
"swn/auth"
)
func main() {
message := auth.Auth("Naveen")
fmt.Println(message)
}
Let's trace this:
go.modsays:module swnβ So any package in this project starts withswn/auth/auth.godeclares:package authβ So there's a package calledauthin theswnmodule- Import path becomes:
swn/authβ Combine module name + package name - Access exported functions:
auth.Auth(...)β Call the capitalizedAuthfunction from theauthpackage
This is the integration. Modules provide the namespace. Packages live within that namespace.
Why Both Are Necessary
Packages alone handle organization: "What code belongs together?"
Modules alone would be abstract: "What do I depend on?"
Together, they answer both questions:
- Modules say: "My project is named
swn, and it might have many packages" - Packages say: "I'm the
authpackage, and I export theAuthfunction" - Import path connects them:
"swn/auth"
Without modules, packages have no namespace. Without packages, modules have nothing to organize.
This is why you can't skip the go.mod file. Go requires this explicit, two-layer organization.
Scaling This Understanding
As your project grows, you'll add more packages:
swn/
βββ go.mod
βββ main.go
βββ auth/
β βββ auth.go # package auth
βββ handlers/
β βββ http.go # package handlers
βββ models/
β βββ user.go # package models
βββ database/
βββ db.go # package database
The pattern stays the same:
- Module name:
swn - Package paths:
swn/auth,swn/handlers,swn/models,swn/database - Imports:
import "swn/handlers",import "swn/models", etc.
Each package has a responsibility. The module ties them together with a common namespace.
Part 4: Why Should You Care?
You might think: "OK, I understand the mechanism. But why does this matter?"
Because this design choice has consequences for how you build systems.
It Enables Reproducible Builds
When you define a module and manage dependencies in go.mod, any developer (or CI/CD system) who clones your project gets the exact same versions of packages you're using.
git clone <your-project>
cd <your-project>
go get # Installs exact versions from go.mod
Your colleague gets identical code. Your CI/CD gets identical code. No surprises about which version they're running.
This seems obvious, but many languages solved this problem much later. Go got it right from the start (even if it took time for modules to arrive in Go 1.11+).
It Scales from One File to a Million Lines
With packages and modules, you can start simple:
swn/
βββ go.mod
βββ main.go
And grow to thousands of packages without changing the mental model:
swn/
βββ go.mod
βββ main.go
βββ auth/
βββ handlers/
βββ models/
βββ database/
βββ cache/
βββ metrics/
βββ logging/
... (and many more)
The import system stays consistent. The package boundaries stay clear. Large codebases don't become chaotic because the foundationβmodules and packagesβenforces structure.
It's About Explicit Boundaries
Go is obsessed with explicitness. No magic. No implicit behavior.
This extends to packages and modules:
- You explicitly declare which package a file belongs to
- You explicitly define what's exported (capitalization)
- You explicitly list your module name
- You explicitly import what you use
This might feel verbose at first, but it serves a purpose: When reading code, you can understand it without knowing implicit context. You don't have to guess which variables are global or which functions are internal. It's all right there.
At scale, this clarity prevents bugs and makes refactoring safer.
The Foundation for Everything Else
Packages and modules aren't sexy. They're the foundation.
Once you understand them, everything else in Go makes more sense:
- Interfaces (package contracts)
- Error handling (explicit imports and returns)
- Testing (test packages in the same module)
- Dependency management (go.mod and go.sum)
- Standard library organization (all standard packages documented consistently)
You're not learning a package system just to import code. You're learning one of Go's core design philosophies: Clear structure at scale.
Wrapping Up: Now You Know Why
Remember that initial confusion? "Why can't I import a local package?"
The answer: Because Go separates organization (packages) from dependency management (modules).
This separation might seem unnecessary at first, but it's actually elegant:
- Packages let you structure your code into logical units with clear boundaries
- Modules let you manage dependencies (yours and others') reproducibly
- Together, they scale from a single-file script to a million-line application without breaking
You declare a module once (go.mod). You organize code into packages. You import by module path. And suddenly, your project has infrastructure that supports growth.
The next time you create a Go project, you'll start with go mod init <name> before you write any code. You'll understand why. And you'll appreciate that Go chose clarity and structure over convenience.
Series Progress
3 of 3Why Go's Variable Syntax is Different (And What It Solves)
Last article
π§ Related Knowledge Network
Interconnected content based on shared technologies, topics, and research
πRelated Articles
4 items
How Go Variables Work: Memory, Safety, and the Runtime
Understand what happens in memory when you declare a variable, how Go manages memory safely, and how design choices enable automatic optimization
Why Go Separated Packages from Modules: A Design Philosophy
Explore the design decisions behind Go's two-layer system, how it solves real problems, and what it teaches us about building scalable systems
Why Go's Variable Syntax is Different (And What It Solves)
Understand why Go's var x int syntax differs from C and Python, and how this choice enables reading code at scale
Why Google Created Go: The Engineering Problems That Changed Cloud Computing Forever
Discover the engineering challenges at Google that led to Go's creation and how it revolutionized cloud computing
β¨ Found 4 related knowledge items across 1 categories