Go Basics #7 Packages and Modules (go mod)

7 min read

The final basics post. Up to now your code has lived in a single file, but real projects involve many files and external libraries working together. This post covers Go’s code organization system.

Two units — packages and modules #

PackageModule
Unitone foldera bundle of folders
Definitionpackage <name> declarationgo.mod file
Analogyclass/namespaceproject/library

One folder = one package, multiple folders + go.mod = one module. The module is the larger unit.

Package — one folder is one unit #

Files in the same folder must all belong to the same package.

folder structure
calculator/
├── add.go         (package calculator)
├── multiply.go    (package calculator)
└── helper.go      (package calculator)

All must start with package calculator. Mixing different package names is a compile error.

main package — runnable program #

main package
package main

import "fmt"

func main() {
	fmt.Println("hi")
}

A folder with package main + func main() is a runnable program. Other packages are libraries — code that other code imports and uses.

Visibility — capitalized first letter #

Go’s visibility is very simple.

A name starting with a capital letter is exported (visible outside); lowercase means visible only within the package (unexported).

visibility
package math

// usable from outside
func Add(a, b int) int {
	return a + b
}

// usable only in this package
func square(n int) int {
	return n * n
}

// exported constant
const Pi = 3.14159

// internal constant
const initialBuffer = 1024

There are no separate public/private keywords like other languages. You can tell visibility from the first letter alone.

import — bringing in another package #

Standard library #

standard imports
import "fmt"
import "strings"
import "time"

// or grouped
import (
	"fmt"
	"strings"
	"time"
)

Multiple imports — the grouped form is recommended. Alphabetical sorting is done automatically by goimports.

External packages #

external packages
import (
	"github.com/google/uuid"
	"github.com/spf13/cobra"
)

A GitHub-URL-style path becomes the import path directly. That’s why Go’s module system is URL-based.

Aliases and dot imports #

aliases
import (
	f "fmt"               // alias
	. "strings"           // dot — use names directly without the package name (not recommended)
	_ "github.com/.../init"   // blank identifier — side effects only (runs init)
)

f.Println("hi")

Aliases help when names clash. Dot imports are almost never used because it becomes hard to tell where a function comes from. The blank identifier (_) is for when you only want a package’s init function to run (driver registration, etc.).

Module — go.mod #

A bundle of multiple packages. The go.mod file is the entry point of a module.

start a new module
mkdir myapp
cd myapp
go mod init github.com/curtis/myapp

A go.mod is created.

go.mod
module github.com/curtis/myapp

go 1.22
  • module — the module’s import path. Other code uses it like import "github.com/curtis/myapp/...".
  • go — minimum Go version used.

Module name — why a GitHub path? #

Go identifies modules by URL. Typically the GitHub/GitLab repository path becomes the module name as-is.

common patterns
module github.com/curtis/myapp
module gitlab.com/team/service
module example.com/internal/utils    // private domain

Even if you have no plan to push to GitHub, you follow this format. When Go downloads a dependency it goes to that URL (when it’s a public repo).

A module with multiple packages #

multiple packages
myapp/
├── go.mod   (module github.com/curtis/myapp)
├── main.go  (package main)
├── auth/
│   ├── login.go      (package auth)
│   └── register.go   (package auth)
└── db/
    └── connection.go (package db)

Using other packages from main.go:

main.go
package main

import (
	"fmt"
	"github.com/curtis/myapp/auth"
	"github.com/curtis/myapp/db"
)

func main() {
	conn := db.Connect()
	user := auth.Login("Curtis", "secret")
	fmt.Println(conn, user)
}

Packages within the same module — imported by module name + folder path.

Adding external packages — go get #

add a package
go get github.com/google/uuid

This command:

  1. Downloads the package
  2. Adds the dependency to go.mod
  3. Records a checksum in go.sum
updated go.mod
module github.com/curtis/myapp

go 1.22

require github.com/google/uuid v1.6.0

go.sum records exact versions of dependencies — guaranteeing that someone else builds with exactly the same versions.

Usage #

using an external package
package main

import (
	"fmt"
	"github.com/google/uuid"
)

func main() {
	id := uuid.New()
	fmt.Println(id)
}

go mod tidy — clean up #

tidy dependencies
go mod tidy

It analyzes the code and:

  • Adds dependencies used but missing from go.mod
  • Removes dependencies in go.mod that aren’t used

A good habit to run at the end of every work session.

Dependency versions — semver #

version notation
require github.com/google/uuid v1.6.0

semver (semantic versioning) — vMAJOR.MINOR.PATCH. The Go module system follows these rules.

  • Major 0 — no stability guarantee (experimental)
  • Major 1+ — compatibility guaranteed (minor/patch updates are compatible)
  • Major 2+ — import path requires a /v2, /v3, etc. suffix
import for v2 and beyond
import "github.com/some/lib/v2"

When a library moves to v2, the import path itself changes. A design that enforces backward compatibility.

Updating versions #

updates
# latest of a package
go get github.com/google/uuid@latest

# specific version
go get github.com/google/uuid@v1.5.0

# all dependencies to latest
go get -u ./...

# only minor/patch latest
go get -u=patch ./...

Within a module — internal packages #

internal folder
myapp/
├── go.mod
├── main.go
├── api/
│   └── handler.go        (package api)
└── internal/
    └── auth/
        └── login.go      (package auth)

Packages inside an internal/ folder are importable only by that module or its descendants. External modules can’t import them.

When building a library — a standard pattern for separating the exposed API from implementation details.

Package init function #

Each package can have an init() function that runs automatically when the package is first imported.

init
package config

import "log"

var Settings map[string]string

func init() {
	Settings = loadFromFile()
	log.Println("config loaded")
}

Used for side effects (loading settings, registering drivers). Multiple inits in one package are also allowed.

Avoid abusing it — an explicit initialization function (Init()) is usually clearer.

vendor folder — optional #

The go mod vendor command copies dependencies into the project.

vendor
go mod vendor

All dependency code lands in a vendor/ folder. The build pulls from there.

Pro: builds without external download (offline, private network). Con: the repository grows.

These days go.mod + the local cache ($GOPATH/pkg/mod) is enough, so vendor is rarely used.

replace — substitute with a local path #

While developing a library — you can temporarily replace a real dependency with a local path.

go.mod
require github.com/curtis/lib v1.0.0

replace github.com/curtis/lib => ../lib

Code in ../lib is used. Useful when developing a library and the app that uses it at the same time. Usually removed before committing.

Common commands at a glance #

module commands
go mod init <name>      # new module
go mod tidy             # tidy dependencies
go mod download         # download specified dependencies only
go get <pkg>            # add a dependency
go get -u <pkg>         # update
go mod why <pkg>        # why this dependency is needed
go mod graph            # print dependency graph
go mod vendor           # create vendor folder

Lightweight guide — package design #

The full list of design guidelines is hard to summarize, but a few that come up often:

  1. Package names are short and lowercaseauth, db, config
  2. If a package does many things, split it
  3. Cyclic dependency (A → B, B → A) is a compile error — a sign your design is tangled
  4. Minimize external exposure — start lowercase when possible
  5. Avoid catch-all packages like util, common — intent isn’t visible

Wrap-up #

Through 7 basics posts we covered:

  1. Getting started and the first program — setup and Hello World (#1)
  2. Variables, types, constants:=, iota, untyped const (#2)
  3. Control flow — for is the only loop, no fallthrough in switch (#3)
  4. Functions, multiple return, errorif err != nil, defer (#4)
  5. Collections — how slices work, comma-ok for maps (#5)
  6. Structs and methods — value/pointer receivers, embedding (#6)
  7. Packages and modules — visibility, go.mod, internal (this post)

With this you can confidently build small programs and CLI tools. The next Intermediate series covers Go’s real strengths — interfaces, concurrency (goroutines/channels), context, and the standard library.

X