Skip to main content

Command Palette

Search for a command to run...

Go Packages and Modules explained

Updated
16 min read

What is a package?

In Go, every Go program is made up of packages. A package is a directory of .go files that share the same package declaration. The primary purpose of packages is to help you isolate and reuse code.

myapp/
├── main.go       ← package main
└── math/
    ├── add.go    ← package math
    └── sub.go    ← package math

Both add.go and sub.go declare package math. They can call each other's functions directly — no import needed within the same package.

Inside a package, every .go file should begin with a package {name} statement which indicates the name of the package that the file is a part of. Every exported identifier (capitalized name) in that directory is accessible to anyone who imports the package.

Here's what that looks like in practice:

// math/add.go
package math

// pi is an unexported variable.
var pi = 3.14159

// Add returns the sum of two integers.
// Exported — starts with a capital letter.
func Add(a, b int) int {
    return a + b
}
// math/sub.go
package math

// Exported — starts with a capital letter.
func Subtract(a, b int) int {
    
    return a-b
}
// main.go
package main

import (
    "fmt"
    "github.com/yourname/myapp/math"
)

func main() {
    fmt.Println(math.Add(3, 4))      // 7
    fmt.Println(math.Subtract(10, 3)) // 7
    // fmt.Println(math.pi) — compile error: unexported
}

Two rules to remember:

  • Capital letter = exported (public). Lowercase = unexported (private to the package).

  • One package per directory. One directory per package.


What is a module?

If a package is a folder, a module is the whole project — a tree of packages with a name, a Go version requirement, and a list of external dependencies.

When you start a Go project, you create a module, and inside that module, there will be packages.

Every Go project has exactly one go.mod file at its root. That file defines the module. Here's what a real one looks like:

module github.com/yourname/weather-cli

go 1.21

require (
    github.com/aws/aws-sdk-go-v2 v1.24.0
    github.com/aws/aws-sdk-go-v2/service/s3 v1.47.0
    gopkg.in/yaml.v3 v3.0.1
)

Three things in every go.mod:

  • module — the module path. This is the base import path for every package in your project. It's usually your GitHub URL, but it can be anything.

  • go — the minimum Go version your code requires.

  • require — the external dependencies your module needs, each pinned to an exact version.

go.sum — the lockfile

Alongside go.mod lives go.sum. You never edit this by hand. It contains cryptographic checksums for every dependency (and their dependencies) your module uses:

github.com/aws/aws-sdk-go-v2 v1.24.0 h1:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=
github.com/aws/aws-sdk-go-v2 v1.24.0/go.mod h1:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=

go.sum guarantees that the code you download is bit-for-bit identical to what was there when you first added the dependency. It's your protection against supply chain attacks and "works on my machine" problems. Always commit both go.mod and go.sum.

Working with modules day to day

Creating a new module

So, let's say you want to start a new project: you create the folder first, then you create your go.mod file, passing your module path to it (github.com/yourname/myapp).

mkdir myapp && cd myapp
go mod init github.com/yourname/myapp

This creates go.mod with your module path. That's it — you're ready to write Go.

Adding a dependency

go get github.com/aws/aws-sdk-go-v2/service/s3@latest

go get does three things: downloads the package, adds it to go.mod, and updates go.sum. After running it your go.mod will have a new require entry.

You can also just write the import in your code and run go mod tidy — it figures out what's missing and adds it:

go mod tidy

go mod tidy is the command you'll run most often. It adds missing dependencies and removes unused ones, keeping go.mod clean.

Upgrading a dependency

# Upgrade to the latest minor/patch version
go get github.com/aws/aws-sdk-go-v2@latest

# Upgrade to a specific version
go get github.com/aws/aws-sdk-go-v2@v1.25.0

Removing a dependency

Remove the import from your code, then run:

go mod tidy

go mod tidy will remove the require entry automatically if nothing in your code imports it anymore.

Viewing your dependency tree

go mod graph

This prints the full dependency graph — your dependencies, their dependencies, and so on. Useful for debugging version conflicts.

Vendoring dependencies

For environments without internet access (some CI setups, air-gapped servers), you can vendor all dependencies into a local vendor/ directory:

go mod vendor

After vendoring, go build uses vendor/ instead of the module cache. Commit vendor/ to your repo and your build never needs to call the internet.


How modules and packages connect

Let's make the relationship tangible. Say you run:

go get github.com/spf13/cobra@v1.8.0

Your go.mod now contains:

require (
    github.com/spf13/cobra v1.8.0
)

Cobra is a module at github.com/spf13/cobra. Inside that module, there are multiple packages:

github.com/spf13/cobra          ← the root package
github.com/spf13/cobra/doc      ← generates documentation
github.com/spf13/cobra/completions ← shell completions

When you import github.com/spf13/cobra in your code, you're importing one specific package from that module. The module is what gets versioned and downloaded; the package is what you actually use in your code.

import (
    "github.com/spf13/cobra"     // imports the root package of the cobra module
)

One require in go.mod, many importable packages available. That's the module/package split.


Package main is special

Every Go program needs exactly one package main with exactly one main() function. That's the entry point. Everything else is a library package — it exports functions and types but can't be run directly.

// This is a runnable program
package main

import "fmt"

func main() {
    fmt.Println("hello from main")
}
// This is a library — can't be run, only imported
package greet

import "fmt"

func Hello(name string) string {
    return fmt.Sprintf("Hello, %s!", name)
}

You can have multiple package main files in a project — but each one must live in its own directory. This is exactly what the cmd/ convention is for, which we'll get to shortly.

How imports work

The import path is always: module path (from go.mod) + directory path from the module root.

module github.com/yourname/myapp   ← module path

myapp/
├── go.mod
└── internal/
    └── config/
        └── config.go              ← import path: github.com/yourname/myapp/internal/config
// Importing your own packages — always use the full module path
import "github.com/yourname/myapp/internal/config"

// Importing from the standard library — no module path needed
import "fmt"
import "net/http"
import "encoding/json"

// Importing a third-party package — use its full module path + sub-path
import "github.com/spf13/cobra"
import "github.com/aws/aws-sdk-go-v2/service/s3"

Aliasing imports

When two packages have the same name, or when a name is too long, you can alias the import:

import (
    "fmt"

    // Alias to avoid conflict with standard library math
    gomath "github.com/yourname/myapp/math"

    // Alias for readability
    yaml "gopkg.in/yaml.v3"
)

func main() {
    fmt.Println(gomath.Add(1, 2))
    _ = yaml.Marshal
}

The blank identifier import

Sometimes you import a package only for its side effects — database drivers are the classic example. The _ alias tells Go "import this but don't use its name":

import (
    "database/sql"
    _ "github.com/lib/pq" // registers the postgres driver via init()
)

Without the _, Go's compiler would complain about an unused import and refuse to compile.


A practical project structure

Here's a real structure that scales from small to large. This is what most production Go projects converge on:

myapp/
├── go.mod
├── go.sum
├── Makefile
│
├── cmd/
│   ├── server/
│   │   └── main.go     ← the HTTP server entry point
│   └── worker/
│       └── main.go     ← the background worker entry point
│
├── internal/
│   ├── handler/
│   │   ├── handler.go
│   │   └── handler_test.go
│   ├── store/
│   │   ├── postgres.go
│   │   └── store.go
│   └── config/
│       └── config.go
│
└── pkg/
    └── validate/
        ├── validate.go
        └── validate_test.go

Let's walk through each directory.

cmd/ — entry points

One subdirectory per runnable binary. Each contains a single main.go. The main.go should do almost nothing except wire dependencies together and start the program.

// cmd/server/main.go
package main

import (
    "log/slog"
    "net/http"
    "os"

    "github.com/yourname/myapp/internal/config"
    "github.com/yourname/myapp/internal/handler"
    "github.com/yourname/myapp/internal/store"
)

func main() {
    cfg := config.Load()

    db, err := store.Connect(cfg.DatabaseURL)
    if err != nil {
        slog.Error("failed to connect to database", "error", err)
        os.Exit(1)
    }

    h := handler.New(db)

    slog.Info("starting server", "port", cfg.Port)
    if err := http.ListenAndServe(":"+cfg.Port, h); err != nil {
        slog.Error("server failed", "error", err)
        os.Exit(1)
    }
}
💡
No business logic in main.go. Ever. It's a wiring diagram, not an application.

internal/ — private application code

The internal/ directory is enforced by the Go toolchain. Packages inside internal/ can only be imported by code in the parent directory tree. Code outside your module cannot import them — not even if they find your repo on the internet.

This is how you keep implementation details private:

// internal/config/config.go
package config

import "os"

type Config struct {
    Port        string
    DatabaseURL string
    LogLevel    string
}

// Load reads config from environment variables.
// This is internal — callers outside this module can't import it.
func Load() Config {
    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }
    return Config{
        Port:        port,
        DatabaseURL: os.Getenv("DATABASE_URL"),
        LogLevel:    os.Getenv("LOG_LEVEL"),
    }
}
// internal/store/store.go
package store

import (
    "context"
    "database/sql"
    "fmt"

    _ "github.com/lib/pq"
)

// Store handles database operations.
type Store struct {
    db *sql.DB
}

func Connect(dsn string) (*Store, error) {
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        return nil, fmt.Errorf("open db: %w", err)
    }
    if err := db.Ping(); err != nil {
        return nil, fmt.Errorf("ping db: %w", err)
    }
    return &Store{db: db}, nil
}

// GetUser fetches a user by ID.
func (s *Store) GetUser(ctx context.Context, id string) (string, error) {
    var name string
    err := s.db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = $1", id).Scan(&name)
    if err != nil {
        return "", fmt.Errorf("get user %s: %w", id, err)
    }
    return name, nil
}

pkg/ — reusable public code

pkg/ contains packages that are safe to import by external projects. Put things here that are genuinely reusable across projects — validation logic, utility functions, shared types. If in doubt, use internal/ instead.

// pkg/validate/validate.go
package validate

import (
    "errors"
    "strings"
)

// Email returns an error if the given string is not a valid email address.
// Simple check — not RFC 5322 compliant, but good enough for most cases.
func Email(email string) error {
    if email == "" {
        return errors.New("email is required")
    }
    if !strings.Contains(email, "@") || !strings.Contains(email, ".") {
        return errors.New("email is invalid")
    }
    return nil
}

// Required returns an error if the string is empty or whitespace only.
func Required(field, value string) error {
    if strings.TrimSpace(value) == "" {
        return fmt.Errorf("%s is required", field)
    }
    return nil
}

A practical example: a CLI tool

GitHub Repository: https://github.com/FerRiosCosta/weather-cli

Let's make this concrete with a small but complete project — a CLI that fetches weather for a city. It shows package structure, separation of concerns, and how packages talk to each other. Let's create the following directory structure:

weather-cli/
├── go.mod
├── cmd/
│   └── weather/
│       └── main.go
└── internal/
    ├── api/
    │   └── api.go        ← HTTP client for weather API
    └── display/
        └── display.go    ← formats output for the terminal

Open your terminal and create the following directories and files:

mkdir weather-cli
cd weather-cli
mkdir -p cmd/weather
touch cmd/weather/main.go
mkdir -p internal/api
touch internal/api/api.go
mkdir -p internal/display
touch internal/display/display.go

Open VSCode while you are in the weather-cli root directory:

code .

Go to the Openweathermap portal and create an account if you don't have one. Once you have created it, your API key will be enabled after a couple of hours.

Open a new terminal from VScode since we are going to run our program from here, but first we need to export our API KEY.

export WEATHER_API_KEY=<your-api-key>

Now, initialize your module (or project) by running the following command:

go mod init

You should see your new go.mod file created now:

module github.com/FerRiosCosta/weather-cli

go 1.26.3

Start filling the files you created with the code below:

// internal/api/api.go
package api

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
)

// WeatherResponse holds the data we care about from the API.
type WeatherResponse struct {
	City        string  `json:"name"`
	Temperature float64 `json:"main.temp"`
	Description string  `json:"weather.0.description"`
}

// apiResponse mirrors the actual nested JSON structure from OpenWeatherMap.
// Go's JSON decoder doesn't support dot notation like `json:"main.temp"` —
// nested fields require nested structs.
//
// The API returns something like:
//
//	{
//	  "name": "Asuncion",
//	  "main": { "temp": 28.4 },
//	  "weather": [{ "description": "partly cloudy" }]
//	}
type apiResponse struct {
	Name string `json:"name"`
	Main struct {
		Temp float64 `json:"temp"`
	} `json:"main"`
	Weather []struct {
		Description string `json:"description"`
	} `json:"weather"`
}

// Client makes requests to the weather API.
type Client struct {
	apiKey     string
	httpClient *http.Client
}

// NewClient creates and returns a new Client configured with the given API key.
// This is a constructor function — Go doesn't have classes or `new` keywords,
// so by convention we define a function named New<TypeName> to build a struct.
func NewClient(apiKey string) *Client {
	return &Client{
		// Store the API key so every request this client makes can use it.
		// It's set once here and reused — no need to pass it on every call.
		apiKey: apiKey,
		// Create a default HTTP client for making requests.
		// We initialize it here rather than inside GetWeather so it's
		// reused across calls — http.Client maintains a connection pool
		// internally, so reusing it is more efficient than creating a new
		// one each time.
		httpClient: &http.Client{},
	}
}

// GetWeather fetches current weather for the given city.
func (c *Client) GetWeather(ctx context.Context, city string) (*WeatherResponse, error) {
	url := fmt.Sprintf(
		"https://api.openweathermap.org/data/2.5/weather?q=%s&appid=%s&units=metric",
		city, c.apiKey,
	)

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, fmt.Errorf("create request: %w", err)
	}

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("fetch weather: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
	}

	// Decode into the nested API struct first.
	var raw apiResponse
	if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
		return nil, fmt.Errorf("decode response: %w", err)
	}

	// Guard against an empty weather array — the API should always return
	// at least one entry, but defensive code is good code.
	description := ""
	if len(raw.Weather) > 0 {
		description = raw.Weather[0].Description
	}

	// Return the clean, flat struct callers actually care about.
	return &WeatherResponse{
		City:        raw.Name,
		Temperature: raw.Main.Temp,
		Description: description,
	}, nil
}
// internal/display/display.go
package display

import (
    "fmt"
    "io"

    "github.com/yourname/weather-cli/internal/api"
)

// Weather prints a formatted weather summary to w.
func Weather(w io.Writer, data *api.WeatherResponse) {
    fmt.Fprintf(w, "City:        %s\n", data.City)
    fmt.Fprintf(w, "Temperature: %.1f°C\n", data.Temperature)
    fmt.Fprintf(w, "Conditions:  %s\n", data.Description)
}
// cmd/weather/main.go
package main

import (
    "context"
    "log/slog"
    "os"

    "github.com/yourname/weather-cli/internal/api"
    "github.com/yourname/weather-cli/internal/display"
)

func main() {
    if len(os.Args) < 2 {
        fmt.Fprintln(os.Stderr, "usage: weather <city>")
        os.Exit(1)
    }

    city := os.Args[1]
    apiKey := os.Getenv("WEATHER_API_KEY")
    if apiKey == "" {
        slog.Error("WEATHER_API_KEY environment variable is required")
        os.Exit(1)
    }

    client := api.NewClient(apiKey)

    weather, err := client.GetWeather(context.Background(), city)
    if err != nil {
        slog.Error("failed to get weather", "city", city, "error", err)
        os.Exit(1)
    }

    display.Weather(os.Stdout, weather)
}

Run it:

go run ./cmd/weather Asuncion
City:        Asuncion
Temperature: 28.4°C
Conditions:  partly cloudy

Notice how each package has one job:

  • api — knows how to talk to the weather API. Nothing else.

  • display — knows how to format output. Nothing else.

  • cmd/weather/main.go — wires them together. Nothing else.

Adding a second output format (JSON, CSV) means a new file in display/. Adding a second weather provider means a new file in api/. Neither change touches the other.


Common gotchas

1. Circular imports — Go forbids them

Package A cannot import package B if package B imports package A. Go will refuse to compile.

// This will not compile
package a imports package b
package b imports package a  ← circular — error

The fix: extract the shared type into a third package that neither A nor B imports from each other.

2. Package name vs directory name

The directory name and the package name don't have to match — but they should. The one common exception is main: the directory is usually named after the binary (server, worker, weather), but the package is always package main.

// Directory: cmd/server/
// File: main.go
package main  // always "main", not "server"

3. Trying to have multiple packages in one directory

Every .go file in a directory must declare the same package name (with the exception of _test.go files, which can use package foo_test for black-box testing). This is a compile error:

myapp/
├── server.go    ← package server
└── handler.go   ← package handler  ← ERROR: can't mix packages in one directory

4. Exporting by accident

If you capitalize a function name, it's exported — immediately accessible to anyone who imports the package. This isn't always what you want, especially for internal helpers. Default to lowercase until you know something needs to be public.


Summary

Go's package and module system is built on a few simple rules that compound into something powerful.

Four things to take away:

  • A package is a folder — everything in it shares a namespace, capital letters are exported.

  • A module is a versioned collection of packages — defined by go.mod, locked by go.sum.

  • Use cmd/ for entry points, internal/ for private code, pkg/ for reusable public code.

  • go mod tidy is your best friend — run it whenever you add, remove, or change dependencies.

The project structure in this post is the same one I'll use for every project on this blog. Every Go + Lambda, Go + Kubernetes, and Go + AWS post builds on it. Once it's familiar, you'll recognize it instantly in any Go project you open on GitHub.