How Go Packages Work

A Complete Guide

Go packages group related source files under a single name to organize code and enable sharing across modules.

The file that won't compile

You are building a CLI tool to parse log files. Your main.go has grown to 400 lines. You decide to move the parsing logic into a separate file called parser.go. You open the new file, write a function, and try to call it from main.go. The compiler rejects the build. You realize that just putting files in the same folder isn't enough. You need to tell Go how these files relate to each other. That relationship is the package.

What a package really is

A package is a collection of Go source files that share a single namespace. Every .go file belongs to exactly one package. Files in the same package can see each other's functions, types, and variables without extra imports. They compile together into a single unit.

Think of a package like a project team. Everyone on the team shares a whiteboard. If Alice writes a function on the whiteboard, Bob can use it immediately because they are on the same team. If Charlie is on a different team, he cannot see Alice's whiteboard. He has to ask the team lead for access. In Go, the team lead is the import statement. The whiteboard is the package namespace.

The package declaration at the top of every file defines which team the file belongs to. The compiler enforces this strictly. All files in a directory must declare the same package name. If one file says package utils and another says package helpers, the build fails.

Convention aside: The package main declaration is special. It marks the package as an executable binary. Only one package in your entire program can be main, and it must contain a main function. Libraries never use package main.

Packages are namespaces, not directories. The directory is just the container.

Minimal example

Here is the smallest working example of two files sharing a package and one file importing it.

// add.go
package calculator

// Add returns the sum of two integers.
func Add(a, b int) int {
    return a + b
}
// main.go
package main

import (
    "fmt"
    "example.com/myapp/calculator" // Import path points to the directory.
)

func main() {
    // Access the exported function via the package name.
    result := calculator.Add(2, 3)
    fmt.Println(result)
}

The Add function starts with a capital letter. That capitalization makes it exported. Exported names are visible to other packages. If Add were add, the compiler would reject the call in main.go with undefined: calculator.add.

Convention aside: Public names start with a capital letter. Private names start lowercase. Go has no public or private keywords. Capitalization is the only mechanism for visibility.

Capital letters export. Lowercase letters hide. That is the rule.

How the compiler handles packages

When you run go build, the compiler scans the directory. It checks the first line of every .go file. If the package declarations mismatch, the compiler stops immediately with an error like package directory contains both package a and package b.

Next, the compiler resolves imports. It looks at the import path example.com/myapp/calculator. It finds the directory, checks the package name inside, and links the symbols. The compiler uses the package name as the qualifier in your code. If the directory is calculator but the files say package calc, you must write calc.Add(), not calculator.Add().

At runtime, packages are just compiled code. There is no overhead for the package boundary. The linker merges everything into the binary. The package structure exists only for organization, compilation, and visibility control.

Convention aside: Run gofmt on every file. It sorts imports and formats code consistently. Most editors run it on save. Do not argue about indentation; let the tool decide.

The compiler is strict about package boundaries. Fix the declaration before you fix the logic.

Import path vs package name

The import path and the package name are independent. You can have a directory called database but the files say package db. The import is import "example.com/app/database", but you use db.Connect(). The compiler uses the package name for the qualifier, not the directory. This allows short aliases without dot imports.

However, the community convention is that the package name matches the last element of the import path. If the directory is database, the package should be package database. Deviating causes confusion. Readers expect the qualifier to match the path.

The module path is the root. The package path is relative to the module. go.mod declares the module. Packages do not care about go.mod directly, but the toolchain uses it to resolve paths.

Convention aside: Modules manage versions. Packages manage code. Do not confuse them. A module can contain many packages. A package belongs to exactly one module.

Follow the convention. Match the package name to the directory name unless you have a strong reason not to.

Realistic package structure

Real projects have packages with multiple files. You might have config.go for types, load.go for logic, and validate.go for checks. Files in the same package share the namespace seamlessly.

// config.go
package config

// Config holds application settings.
type Config struct {
    Port  int
    Debug bool
}
// load.go
package config

import (
    "context"
    "fmt"
)

// Load reads configuration, respecting cancellation.
func Load(ctx context.Context, path string) (*Config, error) {
    // Check for cancellation before expensive work.
    select {
    case <-ctx.Done():
        return nil, ctx.Err()
    default:
        // Proceed with loading.
    }

    if path == "" {
        return nil, fmt.Errorf("config path cannot be empty")
    }

    return &Config{Port: 8080, Debug: false}, nil
}
// main.go
package main

import (
    "context"
    "fmt"
    "log"
    "example.com/myapp/config"
)

func main() {
    ctx := context.Background()
    cfg, err := config.Load(ctx, "app.yaml")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Starting on port %d\n", cfg.Port)
}

The Load function takes a context.Context as the first parameter. This is a standard convention. Functions that perform I/O or long-running work should accept a context to support cancellation and deadlines. The parameter is conventionally named ctx.

Convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Do not hide errors in helper functions unless you wrap them with context.

Context is plumbing. Run it through every long-lived call site.

Pitfalls and errors

Packages introduce a few common traps.

Package name mismatch: If files in a directory have different package names, the compiler stops. Error: package directory contains both package a and package b. Check the first line of every file.

Import cycle: Package A imports B, and B imports A. The compiler rejects this with import cycle not allowed. Break the cycle by extracting shared code to a third package that both A and B can import.

Unexported names: Trying to use a lowercase function from another package. Error: undefined: pkg.lowercaseFunc. Remember, capitalization controls visibility. If you need to test internal logic, write the test in the same package.

Main in library: Putting package main in a package you want to import. You cannot import main. It must be package libraryname. The main package is reserved for executables.

Init abuse: The init() function runs automatically when a package loads. It is useful for setup, but it hides dependencies. Avoid init() if you can pass configuration explicitly. Order of init() calls between files is undefined.

Convention aside: Goroutine leaks happen when a goroutine waits on a channel that never gets closed. Always have a cancellation path. Packages that start background goroutines must provide a way to stop them.

Import cycles are design smells. Extract the shared dependency.

When to use packages

Use package main when you are building an executable binary that needs a main function entry point.

Use a named package like package utils when you are writing reusable code that other packages will import.

Use package internal when you want to restrict access so only packages within your module can import it. The internal directory name triggers this restriction automatically.

Use multiple files within a package when a single file exceeds 300 lines or when distinct responsibilities like types, logic, and tests warrant separation.

Use a single file for tiny packages. Go does not penalize you for keeping small logic in one file.

Keep packages small and focused. One package, one job.

Where to go next