Skip to content
CalliCoder

Golang Packages: A Practical Guide with Code Examples

Published Updated Golang 14 min read

How golang packages work in practice — the file layout Go expects, what exported means, how imports resolve under modules, and a worked example that builds a reusable package from scratch.

Eight sealed modules, each internally structured, gathered by thin lines into one frame.

This is a practical guide to golang packages: how Go expects your files to be laid out, what actually happens when you write import, and how to build one of your own. Every snippet below is a runnable golang packages example rather than a fragment, because the details that trip people up — the difference between a package name and a directory name, why one function is visible and the next is not, only show themselves when the code compiles.

Go’s package system is small. There are perhaps five rules, and once they click, the rest of the language’s tooling stops feeling arbitrary.

What golang packages actually are

A package in Go is a directory of .go files that all declare the same package name. That is the whole definition. There is no manifest listing the members, no explicit export block, no namespace nesting. The directory is the unit of compilation, and the package clause at the top of each file says which package those files belong to.

// greet/greet.go
package greet

func Hello(name string) string {
    return "Hello, " + name
}

Two things follow from this that surprise people arriving from Java or Python.

First, the package name does not have to match the directory name, though by convention it does. The import path is the directory; the identifier you type in your code is the package clause. When they differ, you have to read the source to know what to call it, which is exactly why the convention exists.

Second, files in the same package can see each other’s unexported identifiers without importing anything. Splitting a package across five files is a readability decision, not an architectural one. There is no friend, no internal keyword at the file level, and no import between files of the same package.

How Go organises code on disk

Since Go 1.11, module mode is the only layout worth learning. A module is a tree of packages with a go.mod at the root that names the module path:

mkdir cartkit && cd cartkit
go mod init github.com/example/cartkit
cartkit/
├── go.mod                  → module github.com/example/cartkit
├── main.go                 → package main
├── pricing/
│   ├── pricing.go          → package pricing
│   └── discount.go         → package pricing   (same package, second file)
└── internal/
    └── audit/
        └── audit.go        → package audit

The module path prefixes every import inside the module. pricing/ is imported as github.com/example/cartkit/pricing, regardless of where the directory sits on your disk. This is the part that used to be governed by GOPATH; under modules, the go.mod file decides, and the project can live anywhere.

internal/ is the one directory name the toolchain treats specially. A package under internal/ can only be imported by code rooted in the parent of that internal directory. It is the closest thing Go has to package-private visibility across directory boundaries, and it is enforced by the compiler rather than by convention.

The main package and the main function

An executable is a package named main containing a function main(). Both halves are required — a main package without main() will not link, and a main() in any other package is an ordinary function with an unfortunate name.

// main.go
package main

import (
    "fmt"

    "github.com/example/cartkit/pricing"
)

func main() {
    total := pricing.Apply(2500, pricing.Percent(10))
    fmt.Printf("total: %d cents\n", total)
}
$ go run .
total: 2250 cents

A module can contain many main packages, one per command, conventionally under cmd/. Only directories declaring package main produce binaries; everything else is a library compiled into whatever imports it.

When a package initialises

Before main() runs, every package reachable from it has already been brought up, and the order is defined rather than incidental. Getting this wrong is the source of the nil map and the half-configured client that only fail in production.

An imported package is fully initialised before the package importing it. Within a single package the sequence is: package-level variables first, in dependency order, then every init() function.

The four ordered stages of Go package initialisation, ending with main.
The four stages, in the order the runtime performs them.

Dependency order means the compiler works out what depends on what, not what you wrote first:

package config

var Endpoint = base + "/v2"   // declared first, initialised second
var base = "https://api.example.com"

func init() {
    if Endpoint == "" {
        panic("config: endpoint not set")
    }
}

Endpoint is written above base and still gets the right value, because it depends on base and the compiler orders them accordingly. Circular dependencies between package-level variables are a compile error, the same as an import cycle.

A package may declare init() more than once, in one file or spread across several, and all of them run. What the language does not promise is the order between files. The go command happens to hand files to the compiler sorted by name, so in practice a_setup.go initialises before b_routes.go, but that is a property of the build tool, not of Go, and a package whose correctness depends on it will break the day someone renames a file. Anything order-sensitive belongs in one init(), or better, in an explicit constructor the caller invokes.

This is also the mechanism a blank import relies on: the same one a Compose stack leans on to register a Postgres driver. import _ "github.com/lib/pq" binds no name; it exists so that the driver’s init() runs and registers itself with database/sql before your first sql.Open.

Only once every imported package has finished does main’s own initialisation run, and main() is called last.

Exported and unexported names

Go’s visibility rule fits in one sentence: an identifier is exported if its first letter is upper case. That applies to functions, types, struct fields, methods, constants and variables, and it is checked at the package boundary.

// pricing/pricing.go
package pricing

// Percent is exported — callers outside the package can construct one.
type Percent int

// Apply is exported.
func Apply(cents int, off Percent) int {
    return cents - discount(cents, off)
}

// discount is unexported. Invisible outside package pricing,
// freely available to every file inside it.
func discount(cents int, off Percent) int {
    return cents * int(off) / 100
}

Struct fields follow the same rule, one field at a time, which is how a type can be exported while part of its state stays private:

type Cart struct {
    ID    string // exported
    items []Item // unexported: only package pricing can touch this
}

This has a consequence worth internalising early: encoding/json and most reflection-based libraries can only see exported fields. A struct that marshals to {} is nearly always a struct with lower-case field names.

A guide to importing packages

An import declares a path and binds a name in the current file:

import (
    "fmt"                                   // standard library
    "net/http"                              // name is http, not net/http

    "github.com/example/cartkit/pricing"    // module-local
    "github.com/google/uuid"                // third party
)

The bound identifier is the package clause of the imported package, not the last path segment. These usually agree; when they do not, the import looks misleading until you read the target.

Three qualifiers cover the cases where the default is wrong:

import (
    crand "crypto/rand"        // alias: avoids colliding with math/rand
    _     "github.com/lib/pq"  // blank: run init() only, bind nothing
    .     "math"               // dot: dumps names into scope — avoid
)

The blank import is the one you will meet in real code. Database drivers and image format decoders register themselves in an init() function, so importing them purely for that side effect is the intended usage. The dot import exists, is legal, and makes call sites impossible to trace; treat seeing one as a code smell.

Import cycles are a compile error, not a warning. If pricing imports audit and audit imports pricing, the build fails outright. Go offers no lazy-import escape hatch, so a cycle means the package boundary is in the wrong place: usually fixed by extracting the shared types into a third package that both import.

A worked example: building a custom package

Here is a complete golang packages example, from empty directory to running binary. It assumes the toolchain is installed and a module already makes sense to you, if not, the first Go program covers go mod init and the module path.

mkdir -p cartkit/pricing && cd cartkit
go mod init github.com/example/cartkit
// pricing/pricing.go
package pricing

import "errors"

// ErrNegative is returned when a total would drop below zero.
var ErrNegative = errors.New("pricing: total below zero")

type Percent int

// Apply returns the total in cents after off has been deducted.
func Apply(cents int, off Percent) (int, error) {
    if off < 0 || off > 100 {
        return 0, errors.New("pricing: percent out of range")
    }
    total := cents - discount(cents, off)
    if total < 0 {
        return 0, ErrNegative
    }
    return total, nil
}

func discount(cents int, off Percent) int {
    return cents * int(off) / 100
}
// main.go
package main

import (
    "fmt"
    "log"

    "github.com/example/cartkit/pricing"
)

func main() {
    total, err := pricing.Apply(2500, pricing.Percent(10))
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("total: %d cents\n", total)
}
$ go run .
total: 2250 cents

Note what did not happen. No build file listed pricing.go. No export statement named Apply. The directory made the package, the capital A made it visible, and the module path made it importable.

Tests live beside the code they exercise, in the same package:

// pricing/pricing_test.go
package pricing

import "testing"

func TestDiscount(t *testing.T) {
    // Same package, so the unexported discount() is reachable here.
    if got := discount(2500, 10); got != 250 {
        t.Fatalf("discount(2500, 10) = %d, want 250", got)
    }
}
$ go test ./...
ok  	github.com/example/cartkit/pricing	0.002s

A test file may also declare package pricing_test, which compiles as a separate package and can only reach exported names. That is the honest way to test your public surface, and it catches the case where a package is only usable from inside itself.

Installing third-party packages

Under modules there is no separate install step. Import the package, then let the toolchain reconcile:

import "github.com/google/uuid"
$ go mod tidy
go: finding module for package github.com/google/uuid
go: downloading github.com/google/uuid v1.6.0

go mod tidy adds what the code imports and removes what it no longer does, writing both go.mod and the go.sum checksum file. Commit both. go get still exists for pinning a specific version:

go get github.com/google/[email protected]

Downloads are cached in the module cache, not in the project, so several projects sharing a dependency version share one copy on disk.

Naming, and where packages go wrong

Most package-level problems in Go codebases are naming problems, and they are the part of any guide to golang packages that gets skipped and then regretted.

Package names are short, lower case, single words, no underscores, no camelCase, pricing, httputil, audit. They are also not repeated in the identifiers they contain, because the call site already carries the package name. pricing.Apply(...) reads well; pricing.ApplyPricing(...) stutters, and the linters will say so.

Two names to avoid entirely: util and common. Neither describes what is inside, so both accumulate unrelated code until they import half the project and cause the import cycle that forces a refactor. If you cannot name a package after what it does, the grouping is probably wrong, and the usual fix is to define the boundary as an interface in the package that consumes it, which is where Go expects interfaces to live.

Frequently asked questions

What is a package in Go?

A directory of .go files that all declare the same package name. The directory is the unit of compilation and the unit of import.

Does the package name have to match the folder name?

No, but it should. The import path is the directory; the identifier in your code is the package clause. When the two differ, every call site is harder to follow.

How do I make a function available to other packages?

Capitalise its first letter. That is the entire export mechanism, and it applies to types, fields, methods and constants as well.

Why is my struct serialising as an empty JSON object?

Its fields are unexported. encoding/json uses reflection and can only see fields whose names begin with a capital letter.

Can two files be in the same package?

Yes, as many as you like, provided they sit in one directory and declare the same package name. They share scope and can use each other’s unexported identifiers with no import.

What does internal/ do?

Packages under an internal/ directory can only be imported from within the subtree rooted at that directory’s parent. It is compiler-enforced, unlike ordinary naming conventions.

How do I fix an import cycle?

Move the shared declarations into a third package that both of the others import. Go has no way to defer or break a cycle at build time.

Do I still need GOPATH?

No. With a go.mod in the project root, the module path determines imports and the project can live in any directory.

What is the difference between go get and go mod tidy?

go mod tidy derives dependencies from the imports actually present in your code. go get adds or upgrades a specific module at a version you name.

Should go.sum be committed?

Yes. It records the expected checksums of your dependencies, and it is what makes a build reproducible and tamper-evident.

What is a blank import for?

Running a package’s init() for its side effects without binding a name: database drivers and image decoders registering themselves is the canonical case.

Can one module contain more than one executable?

Yes. Each directory declaring package main builds to its own binary, conventionally placed under cmd/<name>/.

When does init() run relative to main()?

After every package-level variable in that package has been initialised, and after all imported packages are fully initialised. main’s init() runs last of all, then main() is called.

Is it safe to rely on the order of init() functions across files?

No. The language does not define it; you are relying on the go command sorting filenames, which is a build-tool detail. Put order-sensitive setup in a single init(), or expose a constructor and let the caller decide when it runs.

Where should I go next?

The rules above are the whole model. If you keep one thing from this guide to golang packages, make it the pair that does the most work: a directory is a package, and a capital letter is an export. Everything the toolchain does afterwards follows from those two.