Skip to main content
Go · lesson 17 of 25
Go beginner Lesson 17 of 25

Packages and Modules in Go

Go packages and modules run for real: a multi-package module, exported names, the internal/ rule, replace and go.work, import cycles, and go doc output.

By SkillByExample · Updated

A package is a directory of .go files compiled together. A module is a tree of packages with a go.mod at its root, and its module path prefixes every import path inside it. This lesson builds a small two-package module, then breaks the visibility rules on purpose to see what the toolchain says.

A module with several packages

The module example.com/shop has a reusable money package, a private internal/tax package and a command in cmd/shop.

module example.com/shop

go 1.24
// Package money represents amounts as integer cents.
package money

import "fmt"

// Cents is an amount of money in hundredths of a unit.
type Cents int64

// FromFloat converts 12.34 to 1234 cents, rounding to the nearest cent.
func FromFloat(f float64) Cents { return Cents(round(f * 100)) }

// String formats c as 12.34.
func (c Cents) String() string { return fmt.Sprintf("%d.%02d", c/100, c%100) }

func round(f float64) int64 { return int64(f + 0.5) } // unexported
package tax

import "example.com/shop/money"

// Add returns c plus pct percent tax, rounded down to the cent.
func Add(c money.Cents, pct int) money.Cents {
	return c + c*money.Cents(pct)/100
}
package main

import (
	"fmt"

	"example.com/shop/internal/tax"
	"example.com/shop/money"
)

func main() {
	price := money.FromFloat(19.99)
	fmt.Println("price:", price)
	fmt.Println("with 20% tax:", tax.Add(price, 20))
}
$ find . -name '*.go' | sort
./cmd/shop/main.go
./internal/tax/tax.go
./money/money.go
$ go run ./cmd/shop
price: 19.99
with 20% tax: 23.98
$ go list ./...
example.com/shop/cmd/shop
example.com/shop/internal/tax
example.com/shop/money
$ go vet ./...

Import paths are the module path plus the directory: example.com/shop/money. The package name is what follows package and is what you type at call sites (money.FromFloat); by convention it matches the last path element. ./... means “every package under here”, so one go vet ./... checks the whole module, and go list shows that the module contains three packages. fmt.Println used the String method, so the price prints as 19.99 rather than 1999.

Exported and unexported names

A name starting with an upper-case letter is exported; anything else is visible only inside its own package. A second module that uses shop locally (via a replace directive) can’t reach the lower-case helper:

module example.com/client

go 1.24

require example.com/shop v0.0.0

replace example.com/shop => ../raw_shop
package main

import (
	"fmt"

	"example.com/shop/money"
)

func main() {
	fmt.Println(money.FromFloat(2.5))
	fmt.Println(money.round(2.5))
}
$ go run .
# example.com/client
./main.go:11:20: name round not exported by package money

The compiler names the problem precisely. There are no public/private keywords in Go: case is the whole visibility system, and it applies to types, functions, methods, constants, variables and struct fields alike.

The replace line points the example.com/shop requirement at a directory on disk instead of downloading it, which is how you develop two modules side by side. (A go.work workspace, shown in the practice section, does the same without editing go.mod.)

internal/ packages

Any package under a directory named internal can only be imported by code rooted at the parent of that directory. cmd/shop inside the module may import example.com/shop/internal/tax; another module may not:

module example.com/client2

go 1.24

require example.com/shop v0.0.0

replace example.com/shop => ../raw_shop
package main

import (
	"fmt"

	"example.com/shop/internal/tax"
	"example.com/shop/money"
)

func main() {
	fmt.Println(tax.Add(money.FromFloat(10), 5))
}
$ go run .
package example.com/client2
	main.go:6:2: use of internal package example.com/shop/internal/tax not allowed

This is enforced by the go command, not by convention. Put everything that isn’t part of your module’s promised API under internal/, and you can rename or delete it later without breaking anyone.

Import cycles

Package a importing b while b imports a is rejected outright.

module example.com/cycle

go 1.24
package a

import "example.com/cycle/b"

func Hello() string { return "a+" + b.Name() }
package b

import "example.com/cycle/a"

func Name() string { return "b" }

func Both() string { return a.Hello() }
$ go build ./...
package example.com/cycle/a
	imports example.com/cycle/b from a.go
	imports example.com/cycle/a from b.go: import cycle not allowed

Packages are compiled and initialised in dependency order, which needs a graph without cycles. The usual fixes are moving the shared code into a third package both can import, or having one side accept an interface instead of importing the other package.

Documentation comments

A comment directly above a declaration is its documentation, and go doc reads it from source — including your own packages:

$ go doc ./money
package money // import "example.com/shop/money"

Package money represents amounts as integer cents.

type Cents int64
    func FromFloat(f float64) Cents
$ go doc ./money Cents.String
package money // import "example.com/shop/money"

func (c Cents) String() string
    String formats c as 12.34.

The convention is that a doc comment is a full sentence starting with the name it documents. Unexported names (round) don’t appear in go doc output, and the // Package money ... comment becomes the package summary that pkg.go.dev shows.

Naming conventions

ConventionExampleAvoid
Short, lower-case, single wordmoney, tax, httpmoneyUtils, tax_calc
Don’t repeat the package namemoney.Cents, tax.Addmoney.MoneyCents, tax.AddTax
No catch-all namesstrutil, slugutil, common, helpers
Directory matches package namemoney/ holds package moneymoney/ holding package currency

Callers always write the package name, so tax.Add reads well while tax.AddTax stutters.

Practice

1. Add an exported Sub(other Cents) Cents method to money.Cents and use it in cmd/shop to print a 5.00 discount from the price.

Solution
module example.com/shop

go 1.24
package money

import "fmt"

type Cents int64

func FromFloat(f float64) Cents       { return Cents(int64(f*100 + 0.5)) }
func (c Cents) Sub(other Cents) Cents { return c - other }
func (c Cents) String() string        { return fmt.Sprintf("%d.%02d", c/100, c%100) }
package main

import (
	"fmt"

	"example.com/shop/money"
)

func main() {
	price := money.FromFloat(19.99)
	fmt.Println(price.Sub(money.FromFloat(5)))
}
$ go run ./cmd/shop
14.99

The method is exported because it starts with S; cmd/shop sees it through the money import.

2. Break the cycle in raw_cycle: keep a importing b, and remove b’s dependency on a.

Solution
module example.com/cycle

go 1.24
package a

import "example.com/cycle/b"

func Hello() string { return "a+" + b.Name() }

func Both() string { return Hello() } // moved here from b
package b

func Name() string { return "b" }
package main

import (
	"fmt"

	"example.com/cycle/a"
)

func main() { fmt.Println(a.Both()) }
$ go run .
a+b

Both needed both packages, so it moved to the one that already imports the other. The dependency graph is now a line: main → a → b.

3. Instead of a replace directive, use a workspace: create a client module with a plain require, then go work init it together with raw_shop.

Solution
module example.com/client3

go 1.24
package main

import (
	"fmt"

	"example.com/shop/money"
)

func main() { fmt.Println("workspace:", money.FromFloat(7.5)) }
$ cd .. && go work init ./raw_shop ./raw_p3 && cat go.work
go 1.24.7

use (
	./raw_p3
	./raw_shop
)
$ cd raw_p3 && go run .
workspace: 7.50

go.work lists the modules to build together, and imports between them resolve to the local directories. The client’s go.mod didn’t need a require for shop at all. Workspaces are meant for local development, so go.work is usually not committed.

Frequently Asked Questions

What is the difference between a package and a module in Go?
A package is one directory of .go files sharing a package clause; it is the unit of compilation and of name visibility. A module is a tree of packages with a go.mod file at its root; it is the unit of versioning and download, and its path is the prefix of every package's import path.
How do I make a function visible to other packages?
Start its name with an upper-case letter. Identifiers beginning with an upper-case letter are exported from their package; lower-case ones are visible only inside the package. The rule applies equally to functions, types, struct fields, methods, constants and variables.
What does the internal/ directory do?
A package under a directory named internal can only be imported by code rooted at internal's parent directory. The go command enforces it, so other modules cannot depend on those packages, leaving you free to change them without breaking anyone.
How do I use a local copy of another module while developing?
Either add a replace directive to go.mod, such as replace example.com/shop => ../shop, or create a go.work file with go work init listing both module directories. A workspace keeps go.mod untouched, which makes it the better choice for temporary local development.
Why does Go reject import cycles?
Packages are compiled and initialised in dependency order, which requires the import graph to have no cycles. If two packages need each other, move the shared part into a third package, or have one side depend on an interface instead of the concrete package.