Understanding Go Packages and Modules: The Two-Layer System

Discover why Go separates packages from modules, and how this design enables scalable, reproducible builds

β€’11 min readβ€’
πŸ“š

Part 3 of 3

This article

11 min read

Series total

18 min

Remaining

6 min

Progress

100%

πŸ’‘ Estimated times based on average reading speed
Understanding Go Packages and Modules: The Two-Layer System

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:

  • auth package - Authentication logic
  • database package - Database operations
  • handlers package - HTTP request handlers
  • models package - 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:

  1. Downloads the package
  2. Adds it to your go.mod file
  3. 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:

  1. Define a module (go.mod)
  2. Give it a name (module swn)
  3. 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.mod file that says: module swn
  • A main.go file in the root (the main package, entry point)
  • An auth/ folder with auth.go (the auth package)
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:

  1. go.mod says: module swn β€” So any package in this project starts with swn/
  2. auth/auth.go declares: package auth β€” So there's a package called auth in the swn module
  3. Import path becomes: swn/auth β€” Combine module name + package name
  4. Access exported functions: auth.Auth(...) β€” Call the capitalized Auth function from the auth package

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 auth package, and I export the Auth function"
  • 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.

🧠 Related Knowledge Network

Interconnected content based on shared technologies, topics, and research

✨ Found 4 related knowledge items across 1 categories

πŸ› οΈ Technologies & Concepts

Questions or feedback? Reach out via contact.