Packages and Modules in Go
Understand Go modules, go.mod, writing and importing packages, internal packages, and package naming conventions.
Go Modules
Before modules, Go projects had to live inside a strict GOPATH directory and had no reliable way to pin dependency versions. Modules, introduced in Go 1.11 and the default since 1.16, solve both problems. A module is a directory tree with a go.mod file at its root. The go.mod file declares the module’s import path (its unique identifier) and records the minimum version of every dependency needed to build it reproducibly on any machine.
go mod init github.com/yourname/myapp
go.mod example:
module github.com/yourname/myapp
go 1.22
require (
github.com/gin-gonic/gin v1.9.1
github.com/jmoiron/sqlx v1.3.5
)
require (
// indirect dependencies are managed automatically by go mod tidy
golang.org/x/net v0.17.0 // indirect
)
Project Layout
Go has no enforced project structure, but the following layout is idiomatic and recognised across the ecosystem. The key insight is the separation between cmd/ (executable entry points), internal/ (private implementation), and pkg/ (reusable public code). Keeping main.go thin — just wiring — and putting logic in packages makes testing and reuse straightforward.
github.com/yourname/myapp/
├── go.mod
├── go.sum
├── main.go // package main — single-binary apps
├── cmd/
│ ├── server/
│ │ └── main.go // package main — runs the HTTP server
│ └── migrate/
│ └── main.go // package main — runs DB migrations
├── internal/
│ ├── db/
│ │ └── db.go // package db — private, only for this module
│ └── auth/
│ └── auth.go // package auth
└── pkg/
└── validator/
└── validator.go // package validator — importable by other modules
Writing a Package
A package is a group of .go files in the same directory that share a package declaration. Exported identifiers (functions, types, variables) start with a capital letter and are visible to all importers. Unexported identifiers start with a lowercase letter and are only accessible within the package. This is Go’s primary encapsulation mechanism — there are no public or private keywords; the case of the first letter determines visibility.
// File: internal/math/math.go
package math // package name matches the directory name by convention
import "fmt"
// Add is exported — capital letter makes it visible to importers
func Add(a, b int) int {
return a + b
}
// Multiply is also exported
func Multiply(a, b int) int {
return a * b
}
// clamp is unexported — lowercase means it's package-private
// callers outside this package cannot access it
func clamp(val, min, max int) int {
if val < min {
return min
}
if val > max {
return max
}
return val
}
// Vector2 is an exported type — importers can create and use it
type Vector2 struct {
X, Y float64
}
func (v Vector2) String() string {
return fmt.Sprintf("(%.2f, %.2f)", v.X, v.Y)
}
Importing Packages
The import path is the module path from go.mod plus the relative directory from the module root. Go resolves all imports at compile time, so typos become build errors rather than runtime panics. The blank import (_) is a special case: it runs the package’s init() function for its side effects (registering a database driver, for example) without actually using any of the package’s exported symbols.
// main.go
package main
import (
"fmt"
// Standard library — no module path prefix needed
"net/http"
"encoding/json"
// Your own packages — module path + directory path from module root
"github.com/yourname/myapp/internal/math"
"github.com/yourname/myapp/pkg/validator"
// Third-party dependency declared in go.mod
"github.com/gin-gonic/gin"
// Import with alias — useful when two packages have the same name
mymath "github.com/yourname/myapp/internal/math"
// Blank import — runs init() for side effects only (e.g. driver registration)
_ "github.com/lib/pq" // registers the PostgreSQL driver with database/sql
)
func main() {
fmt.Println(math.Add(3, 4)) // 7
fmt.Println(mymath.Add(3, 4)) // 7 — same package, accessed via alias
}
internal/ Packages
The internal/ directory is a compiler-enforced visibility boundary. Any package inside internal/ can only be imported by code within the parent of that internal/ directory. This lets you build a clean, minimal public API while freely refactoring implementation details inside internal/ without worrying about breaking outside users. It’s the right place for configuration loaders, database helpers, and any other code you want to share within your module but not expose publicly.
myapp/
├── internal/
│ └── config/
│ └── config.go // package config — only importable within myapp/
├── main.go // can import myapp/internal/config ✓
└── api/
└── handler.go // can import myapp/internal/config ✓
// external modules: compiler error ✗
// internal/config/config.go
package config
type Config struct {
DatabaseURL string
Port int
Debug bool
}
// Load reads config from environment variables with sensible defaults
func Load() (*Config, error) {
return &Config{
DatabaseURL: getEnv("DATABASE_URL", "postgres://localhost/myapp"),
Port: getEnvInt("PORT", 8080),
Debug: getEnvBool("DEBUG", false),
}, nil
}
Package Naming Conventions
Good package names are short, lowercase, single-word nouns that describe what the package provides — not what it does to things. Avoid generic names like util, common, or helper that tell callers nothing about the package’s purpose. The package name becomes part of every identifier at the call site (json.Marshal, http.Get), so choose a name that reads naturally at the call site.
// Good package names — short, lowercase, describe the domain
package http
package json
package sync
package validator
// Avoid these patterns
package httputils // vague — what utilities? just call it http or name it for its purpose
package mypackage // "my" is meaningless
package Utils // no uppercase in package names
// When a package name collides with a local variable, use an alias
import (
nethttp "net/http"
"myapp/internal/http" // your http package
)
// Package doc comment — appears in go doc and pkg.go.dev
// Package validator provides request validation utilities.
package validator
go mod Commands
The go mod command is your interface to the module system. go mod tidy is the most important one — run it before committing to ensure go.mod and go.sum are consistent with the actual imports in your code. go get manages individual dependencies. go mod vendor copies all dependencies into a local vendor/ directory for offline or audited builds.
# Initialize a new module
go mod init github.com/yourname/myapp
# Add a dependency at a specific version
go get github.com/gin-gonic/gin@v1.9.1
# Update a dependency to the latest compatible version
go get github.com/gin-gonic/gin@latest
# Remove unused imports, add missing ones, update go.sum
go mod tidy
# Copy all dependencies into ./vendor/ for reproducible offline builds
go mod vendor
# Show the full dependency graph
go mod graph
# Verify that downloaded modules match go.sum checksums
go mod verify
# Explain why a module is a dependency
go mod why github.com/some/package
# Pre-download all modules to the local cache
go mod download
go.sum File
go.sum stores cryptographic hashes of every module version your project depends on — directly and transitively. When you run go mod verify, Go checks that the downloaded modules match these hashes. If a dependency is tampered with (or replaced by a malicious version on the module proxy), the build fails immediately. Commit go.sum to version control — it’s what makes Go builds reproducible and auditable.
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
Commit go.sum to version control. It ensures reproducible builds — if a dependency is tampered with, go mod verify will fail.
Working With Multiple Modules (Workspace)
When developing two related modules simultaneously — for example, an app and a library it depends on — you normally have to push and tag intermediate versions before the app can pick them up. Go workspaces eliminate this friction. A go.work file tells the Go toolchain to resolve certain module paths from local directories instead of the module proxy, letting you iterate on both modules together without any publishing step.
# Create a workspace that uses both local modules
go work init ./myapp ./mylib
# go.work file
go 1.22
use (
./myapp
./mylib
)
With a workspace, Go resolves imports from local directories rather than the module proxy — no need to push and tag intermediate versions while developing.
Practical Example — Shared Config Package
This example shows a well-structured config package: it lives in pkg/ so it can be shared, it validates its inputs eagerly and returns errors rather than panicking, and it uses unexported helpers to keep the exported API minimal. Loading config once at startup and passing the struct down through constructors is the idiomatic Go approach — no global config variables needed.
// pkg/config/config.go
package config
import (
"fmt"
"os"
"strconv"
"time"
)
type Config struct {
HTTPPort int
DatabaseURL string
JWTSecret string
LogLevel string
ReadTimeout time.Duration
}
// Load reads configuration from environment variables.
// It returns an error if any required variable is missing or malformed.
func Load() (*Config, error) {
port, err := strconv.Atoi(getEnv("HTTP_PORT", "8080"))
if err != nil {
return nil, fmt.Errorf("invalid HTTP_PORT: %w", err)
}
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
return nil, fmt.Errorf("DATABASE_URL is required")
}
return &Config{
HTTPPort: port,
DatabaseURL: dbURL,
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
LogLevel: getEnv("LOG_LEVEL", "info"),
ReadTimeout: 30 * time.Second,
}, nil
}
// getEnv returns the environment variable named by key,
// or fallback if the variable is not set or empty.
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}