When one file becomes a maze
You start a Go project with a single main.go. It has fifty lines. You can see the whole program on one screen. You add a database connection. Then an HTTP handler. Then a function to format dates. Then a struct to represent a user. Suddenly main.go is eight hundred lines long. You spend thirty seconds scrolling just to find the function you wrote five minutes ago. You try to rename a variable and accidentally break three unrelated features because they share the same namespace. You realize that one file is no longer working for you.
This is the moment you split your code into packages.
What a package actually is
A package in Go is a namespace and a privacy boundary. It groups related code together and controls what the outside world can see. Think of a package like a room in a house. The room has a name, like "Kitchen" or "Bedroom". Inside the room, you have tools and ingredients. Some things are for everyone to use, like the fridge. Other things are hidden behind a door, like the spare key under the mat.
In Go, exported names start with a capital letter. Unexported names start with a lowercase letter. The package boundary decides who gets the key. Code inside the package can access everything. Code outside the package can only see the capitalized names. The compiler enforces this rule strictly. You cannot bypass it with reflection or tricks. The boundary is real.
Files versus packages
A common confusion for newcomers is thinking a package must be a single file. It isn't. A package is a collection of files in the same directory that share the same package declaration. You can split a large package across multiple files.
For example, a db package might have db.go for the connection pool, queries.go for SQL strings, and models.go for structs. They all start with package db. They can see each other's unexported variables. They act as one unit to the outside world. This keeps files manageable without creating new import boundaries.
Use multiple files within a package when the package is getting too large for one file but the code is still too tightly coupled to split into separate packages. Keep files under 500 lines as a rule of thumb. If a file exceeds that, look for a natural split point. If the split creates code that could be useful elsewhere, create a new package. If the split is just to shrink the file, keep it in the same package and add a new file.
A minimal example of the problem
Here is a main.go that has grown too big. It mixes database logic, formatting utilities, and the entry point.
// main.go
package main
import "fmt"
// Global state that bleeds everywhere
var dbConn string
func main() {
dbConn = "localhost:5432"
fmt.Println(Connect())
fmt.Println(FormatName("Alice", "Smith"))
}
// Connect does database stuff but lives in main
func Connect() string {
return "Connected to " + dbConn
}
// FormatName is a utility that has nothing to do with DB
func FormatName(first, last string) string {
return first + " " + last
}
This code compiles and runs. It also has a design flaw. FormatName is trapped in main. The main package is special. It can only be used to build executables. You cannot import main from another package. If you want to use FormatName in a test or a different tool, you can't. You have to copy the code. Copying code leads to divergence. Divergence leads to bugs.
Splitting FormatName into its own package solves this. It also removes the dependency on dbConn. The utility function becomes pure and reusable.
How the compiler handles packages
When you split code, the compiler treats each package as a separate unit. It compiles packages independently. This has a major benefit for your workflow. Go caches compiled packages. When you run go build, the compiler checks if a package has changed. If the source files in a package haven't changed, Go uses the cached object file.
Splitting code into packages speeds up incremental builds. If you change a function in auth, only auth and its dependents need recompilation. The rest of the project stays cached. A large monolithic package forces recompilation of everything when you touch a single line. Small packages mean faster feedback loops.
At runtime, there is no overhead for packages. The binary is linked together. The package structure exists to help you write code, not to slow down execution. The compiler resolves all imports and types at build time. The resulting binary contains all the code you need.
A realistic split
Here is how you might restructure a project with authentication logic. The auth code moves to internal/auth. The internal directory is a Go convention with a compiler-enforced rule. Packages inside a directory named internal can only be imported by code in the parent directory or its descendants. This protects your implementation details from external consumers.
// internal/auth/token.go
package auth
import "fmt"
// Token represents a user session.
// We export Token so handlers can read the user ID.
type Token struct {
UserID string
}
// Validate checks if a token string is valid.
// This function is exported because external packages need to verify tokens.
func Validate(raw string) (*Token, error) {
// Implementation details hidden
if raw == "valid-token" {
return &Token{UserID: "123"}, nil
}
return nil, fmt.Errorf("invalid token")
}
// secretKey is unexported.
// Only code inside the auth package can access this.
var secretKey = "super-secret"
// cmd/server/main.go
package main
import (
"fmt"
"myproject/internal/auth"
)
func main() {
// We can use auth.Validate because it's exported.
token, err := auth.Validate("valid-token")
if err != nil {
fmt.Println(err)
return
}
fmt.Println("User:", token.UserID)
// This would fail to compile:
// fmt.Println(auth.secretKey)
}
The auth package exposes Token and Validate. It hides secretKey. The main package uses the public API. It cannot reach inside auth to grab the secret key. This forces a clean interface. If you later change how secretKey is stored, you only update auth. The main package doesn't care.
Naming conventions and structure
Package names matter. The name becomes part of the import path and the namespace. If you name a package myawesomeauth, every call looks like myawesomeauth.Validate. That is verbose and painful to type. Go convention prefers short, lowercase names. auth is better. db is better. mailer is better.
The directory can be long, but the package name inside should be short. You can have a directory internal/authentication-service with a package declaration package auth. The compiler uses the last element of the import path as the default name, so structure your directories to match your desired names.
Avoid names like utils, common, or lib. These are dependency magnets. Every package imports utils, and changing utils breaks everything. Name packages after what they do, not their structure. slices manipulates slices. net/http handles HTTP. auth handles authentication.
Trust the convention. Short names reduce cognitive load. When you see auth.Validate, you know exactly where the code lives. When you see utils.DoSomething, you have to search.
Pitfalls and compiler errors
The most common error when splitting packages is the import cycle. If package A imports package B, and package B imports package A, the compiler stops with import cycle not allowed. This happens when two packages depend on each other too tightly. The fix is usually to extract the shared dependency into a third package, or move the code to the package that owns the data.
If you try to access a lowercase field from another package, the compiler rejects it with auth.secretKey undefined (type auth has no field or method secretKey). This is a feature. It forces you to design a clean API. You cannot just reach in and grab a variable. You have to call a function.
Another pitfall is the "god package". This is a package that grows too large because you keep dumping unrelated code into it. It becomes hard to navigate and creates unnecessary dependencies. If a package contains database queries, email sending, and math utilities, split it. Each domain deserves its own package.
Goroutine leaks can also hide behind package boundaries. If a package starts a background goroutine and doesn't provide a way to stop it, the consumer cannot clean up. Always design packages with lifecycle management in mind. If a package starts work, it should offer a Close or Shutdown method.
When to split and when not to
Use a single package when the code is small and tightly coupled, like a simple CLI tool under 200 lines.
Use a new package when you want to hide implementation details from the rest of the project.
Use a new package when multiple files share a logical domain, such as database queries or authentication logic.
Use the internal directory when you need to enforce that code cannot be imported by external modules.
Use a separate module when the code can be reused across different projects and has its own versioning lifecycle.
Avoid a utils package when you can name the package after its purpose instead of its structure.
Packages are for humans. The compiler doesn't care, but your future self will.