Notes · Learn Go › LESSON 8 OF 8

Packages & Modules

How Go organizes code - and why uppercase letters are the access-control system

Contents
  1. Packages: directories of related code
  2. Modules: versioned bundles of packages
  3. The exported names rule (capitalization = visibility)
  4. Importing: by path, not by name
  5. The standard library tour
  6. Project layout: a sensible default
  7. Real-world examples
  8. Gotchas
  9. Glossary

Packages: directories of related code

In Go, a package is simply a directory full of .go files that all declare the same package <name> at the top. That's it. No __init__.py, no class hierarchy, no manifest file. The directory is the package.

myapp/ (project root) main.go package main myapp/utils/ (subdirectory) strings.go package utils numbers.go package utils Both files form ONE package "utils" import Legend root directory same package strings.go and numbers.go see each other's unexported symbols - no import needed
Two files in utils/ both declare "package utils" - they form a single package with a shared namespace.
Python - module = file
# myapp/utils/__init__.py  (required)
# myapp/utils/strings.py
def truncate(s, n):
    return s[:n]

# myapp/utils/numbers.py
def clamp(x, lo, hi):
    return max(lo, min(hi, x))

# to use both:
from myapp.utils.strings import truncate
from myapp.utils.numbers import clamp
Go - directory = package
// myapp/utils/strings.go
package utils

func Truncate(s string, n int) string {
    return s[:n]
}

// myapp/utils/numbers.go
package utils

func Clamp(x, lo, hi int) int { ... }

// to use both - one import:
import "github.com/sunilt/myapp/utils"
The 5-year-old version
A package is a toolbox. All the tools inside - functions, types, constants - work together because they live in the same box (directory). To use a tool from another toolbox, you have to open that box explicitly with import. You can't just reach in without asking.

Multiple files in the same directory? That just means multiple compartments in the same toolbox. They all share one lock. From outside the box, you still just import the box.

Modules: versioned bundles of packages

A module is a tree of packages that are versioned and released together. It is defined by a go.mod file at the root of the tree. Think of it as the container that wraps your entire project - and declares what external code it depends on.

go.mod
module github.com/sunilt/myapp

go 1.23

require (
    github.com/spf13/cobra v1.7.0
    k8s.io/client-go       v0.28.4
)

Three things in every go.mod:

Module commands

go mod init

Create a brand-new module. Run once per project.

$ go mod init github.com/you/myapp

Creates go.mod. Equivalent to pip init + creating setup.py.

go get

Add or update a dependency. Fetches + pins the version.

$ go get github.com/spf13/cobra@v1.7.0

Updates go.mod and go.sum. Like pip install cobra==1.7.0.

go mod tidy

Reconcile go.mod with what your code actually imports.

$ go mod tidy

Adds missing entries, removes unused ones. Run before every commit.

MODULE (go.mod) github.com/sunilt/myapp the whole repo, versioned as a unit PACKAGES (directories) cmd/myapp package main internal/server package server pkg/client package client FILES (.go) main.go server.go handlers.go client.go 1 module contains N packages. Each package contains M files. All versioned as one unit.
Module (go.mod) at the top, packages (directories) in the middle, .go files at the bottom. Each level has a clear job.
Python - requirements.txt + setup.py
# requirements.txt
click==8.1.7
requests==2.31.0

# setup.py (or pyproject.toml)
setup(
  name="myapp",
  version="1.0.0",
  install_requires=["click", "requests"],
)

# two separate files, often out of sync
Go - go.mod (single source of truth)
module github.com/sunilt/myapp

go 1.23

require (
    github.com/spf13/cobra v1.7.0
    github.com/pkg/errors  v0.9.1
)

// go.sum holds cryptographic hashes
// run "go mod tidy" to keep in sync

The exported names rule (capitalization = visibility)

This is the Go quirk that surprises every Python developer. Go has no public / private keywords. Instead, the first letter of a name determines its visibility:

This applies to everything: functions, types, struct fields, methods, constants, and package-level variables.

UNEXPORTED (package-only) func calculateSum (a, b int) int { ... } lowercase "c" - staff only same package // OK other package // compile error EXPORTED (public - anyone can use) func CalculateSum (a, b int) int { ... } Uppercase "C" - public welcome same package // OK other package // OK too vs
One letter change - lowercase "c" to uppercase "C" - is the entire access-control system. The compiler enforces it.
utils/math.go
package utils

// Exported - other packages can call this
func Add(a, b int) int { return a + b }

// Unexported - only code inside package utils can call this
func validate(n int) bool { return n > 0 }

// Exported type with a mix of exported and unexported fields
type Counter struct {
    Value   int    // exported - anyone can read/set
    lastRun string // unexported - internal detail
}
Python - convention only
# Leading underscore = "please don't"
# but Python won't stop you
def _internal_helper():
    pass

# Callers can still do:
from mymod import _internal_helper
_internal_helper()  # works, no error
Go - enforced by compiler
// lowercase = hard boundary
func internalHelper() { ... }

// From another package:
import "github.com/x/mymod"
mymod.internalHelper()
// compile error:
// cannot refer to unexported name
// mymod.internalHelper
The 5-year-old version
Imagine a building with two kinds of doors. Doors with a big "PUBLIC" sign (uppercase first letter) - anyone can walk in. Doors marked "STAFF ONLY" (lowercase first letter) - the security guard (compiler) physically blocks everyone else. In Python, "staff only" is just a sticky note. Anyone can ignore it. In Go, it's a locked door with a bouncer.

Importing: by path, not by name

Go imports use the full module path as the import string. The package name you use in code is just the last segment (usually), but the import string itself is the full URL-like path.

main.go
package main

import (
    "fmt"                                 // stdlib - short path
    "net/http"                            // stdlib - slash = subdirectory
    "github.com/spf13/cobra"              // external - full module path
    "github.com/sunilt/myapp/utils"        // your own subpackage

    f   "fmt"                             // alias: use as f.Println(...)
    _   "github.com/lib/pq"               // blank import: run init(), discard name
)

Import forms

FormSyntaxWhen to use
Normal import "net/http" 99% of imports. Use the package as http.Get(...).
Aliased import f "fmt" Avoid name collisions, or shorten a verbose package name.
Blank import _ "lib/pq" Load the package's init() function for side effects (e.g., register a database driver) without using any of its names.
Dot import . "testing" Merge the package's exported names into the current namespace. Rarely used - avoid unless writing test helpers.
main.go package main fmt stdlib net/http stdlib cobra external myapp/utils your package encoding/json stdlib import arrow (direction = depends on)
main.go imports from stdlib packages (blue), an external package (yellow), and its own subpackage (green). Arrows point in the direction of dependency.
Python - import by name or path
import os
from os import path
from os.path import join, exists
import requests as req

# use:
os.getcwd()
req.get("https://...")
Go - import by full path, use last segment
import (
    "os"
    "path/filepath"
    req "github.com/go-resty/resty/v2"
)

// use:
os.Getwd()
filepath.Join("a", "b")
req.New().Get("https://...")
No relative imports. Go does not support import "./utils". Always use the full module-rooted path: import "github.com/you/myapp/utils". This makes imports unambiguous anywhere in the project.

The standard library tour

Go's standard library is famously comprehensive. You can write HTTP servers, parse JSON, manipulate CSV, run structured logging, and do cryptography - all without a single external dependency. This is deliberate: the Go team maintains and stabilizes the stdlib across versions, so your code doesn't break when npm/pip dependency drama happens.

PackageWhat it doesPython analog
fmt Print, scan, format strings. Printf, Sprintf, Errorf. print(), str.format(), f"..."
os File operations, environment variables, process args, exit codes. os, sys, pathlib
io / bufio Reader/writer interfaces and buffered I/O. Foundation for everything file-related. io, file objects
net/http Full HTTP client AND server. No Flask needed for simple APIs. http.server, requests, flask
encoding/json Marshal structs to JSON, unmarshal JSON into structs. Tag-based field mapping. json module
encoding/csv Read and write CSV files. csv module
time Time values, durations, timers, tickers, time zones. datetime, time
strings / strconv String operations (split, join, trim, replace) and type conversions (int to string, etc.). str methods, int(), str()
sort Sort slices and custom types. sort.Slice takes a less-than function. sorted(), list.sort()
errors Create errors, wrap them (errors.Is, errors.As, fmt.Errorf("%w", err)). Exception types, raise ... from ...
context Request scoping, cancellation signals, deadlines. Pass through call chains. No direct equivalent - somewhat like contextvars
log / log/slog log is simple. slog (Go 1.21+) is structured, leveled logging. logging module
testing The built-in test framework. go test ./... runs all tests. unittest, pytest
examples.go
package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "os"
    "strings"
)

type Person struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

func main() {
    // encoding/json - marshal a struct
    p := Person{Name: "Sunil", Email: "sunilt@nvidia.com"}
    b, _ := json.Marshal(p)
    fmt.Println(string(b)) // {"name":"Sunil","email":"sunilt@nvidia.com"}

    // strings - manipulation
    words := strings.Split("go is fun", " ")
    fmt.Println(words) // [go is fun]

    // os - environment variable
    home := os.Getenv("HOME")
    fmt.Println(home)

    // net/http - a complete server in one call
    http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "ok")
    })
    http.ListenAndServe(":8080", nil)
}

Project layout: a sensible default

Go does not mandate a project structure. But the community has converged on a standard layout that works well for services and CLIs. This is a community convention, not a compiler rule - feel free to adapt it.

myapp/ ├── go.mod # module definition + dependencies ├── go.sum # cryptographic hashes of dependencies ├── cmd/ # entry points - one subdir per binary │ └── myapp/ │ └── main.go # package main, func main() ├── internal/ # PRIVATE - Go enforces no external imports │ ├── server/ │ │ ├── server.go │ │ └── handlers.go │ └── store/ │ └── store.go ├── pkg/ # public packages others can import │ └── client/ │ └── client.go ├── api/ # OpenAPI specs, protobuf definitions, etc. └── README.md

What each directory means

DirectoryPurposeSpecial rules?
cmd/ Each subdirectory is a runnable binary. cmd/myapp/main.go is the entry point for the myapp binary. Multiple subdirs = multiple binaries. No - just convention.
internal/ Private packages. Code inside internal/ can only be imported by code in the same module. Trying to import it from outside gives a compile error. Yes - Go enforces this. The compiler blocks external imports of anything inside internal/.
pkg/ Public packages that other modules can import. Useful for shared client libraries, types, and utilities. No - just convention.
api/ API definitions: OpenAPI/Swagger YAML, protobuf .proto files, JSON schemas. No - just convention.
Small project? Skip the structure. A flat layout - go.mod + a handful of .go files in the root - is perfectly fine for small CLIs and scripts. Add structure only when the flat layout starts feeling cramped.
internal/ is the real gem. It lets you share code across multiple packages inside one module, without making that code part of your public API. Change it however you want - no external callers can break. This is the Go way to have "implementation details."

Real-world examples

BackendHTTP service layout

A typical small HTTP service with cmd/, internal/handlers/, and internal/store/:

cmd/server/main.go
package main

import (
    "log"
    "net/http"

    "github.com/sunilt/myapp/internal/handlers"
    "github.com/sunilt/myapp/internal/store"
)

func main() {
    db := store.New()
    h  := handlers.New(db)

    http.HandleFunc("/users", h.ListUsers)
    http.HandleFunc("/users/", h.GetUser)

    log.Println("Listening on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

The internal/ packages are invisible to anyone outside this module. handlers and store can evolve freely without breaking any external callers.

CLICobra-based CLI tool

Using github.com/spf13/cobra - the library behind kubectl, hugo, and hundreds of other Go CLIs:

cmd/mytool/main.go
package main

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
)

func main() {
    root := &cobra.Command{
        Use:   "mytool",
        Short: "A demo CLI",
    }

    greet := &cobra.Command{
        Use:   "greet [name]",
        Short: "Greet someone",
        RunE: func(cmd *cobra.Command, args []string) error {
            name := "world"
            if len(args) > 0 {
                name = args[0]
            }
            fmt.Printf("Hello, %s!\n", name)
            return nil
        },
    }

    root.AddCommand(greet)
    if err := root.Execute(); err != nil {
        os.Exit(1)
    }
}
terminal
$ go mod init github.com/sunilt/mytool
$ go get github.com/spf13/cobra@v1.7.0
$ go run ./cmd/mytool greet Sunil
Hello, Sunil!
k8sKubernetes client-go

Importing k8s API types and client-go to list pods - the imports themselves illustrate how the k8s module tree is organized:

cmd/podwatcher/main.go
package main

import (
    "context"
    "fmt"
    "log"

    corev1  "k8s.io/api/core/v1"             // k8s API types
    metav1  "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/tools/clientcmd"
)

func main() {
    config, err := clientcmd.BuildConfigFromFlags("", "~/.kube/config")
    if err != nil {
        log.Fatal(err)
    }

    cs, err := kubernetes.NewForConfig(config)
    if err != nil {
        log.Fatal(err)
    }

    pods, err := cs.CoreV1().Pods("").List(
        context.Background(),
        metav1.ListOptions{},
    )
    if err != nil {
        log.Fatal(err)
    }

    for _, pod := range pods.Items {
        var p corev1.Pod = pod
        fmt.Printf("%s/%s\n", p.Namespace, p.Name)
    }
}

Notice the aliased imports (corev1, metav1) - because the last path segment would be collisions like "v1". Aliasing is the standard pattern in the k8s ecosystem.

Gotchas

1. Import path = module path + subdirectory - your module name in go.mod matters from day one.

If your go.mod says module github.com/sunilt/myapp, then to import your utils/ subdirectory you write import "github.com/sunilt/myapp/utils". Renaming the module later breaks every import in every file. Choose it carefully.

2. Lowercase = package-private. The compiler will yell if you try to use it externally.

You will write somePackage.doSomething() and get a confusing error: "cannot refer to unexported name somePackage.doSomething." The fix is always the same: the function author needs to capitalize it, or you're not supposed to call it from outside.

3. Don't import packages by relative paths (./foo) - use full module paths.

Go does not support import "./utils". Write import "github.com/you/myapp/utils" every time. It looks verbose but it means your import statements are always globally unambiguous and work identically from anywhere in the project.

4. internal/ subdirectories can ONLY be imported by code in the same module.

If you publish a library and put something in internal/, nobody outside can import it - not even if they vendor your module. This is intentional: it lets you expose implementation details within your own module without accidentally making them part of your public API.

5. go mod tidy rewrites go.mod and go.sum - run before committing.

After adding an import in code, the entry in go.mod is not automatically updated. Run go mod tidy to sync. Also run it when removing imports. A go.mod that is out of sync causes confusing build failures for everyone who clones the repo.

You did it - all 8 lessons complete!

You now know enough Go to read most production codebases and write real services. You understand types, control flow, functions, collections, structs, pointers, error handling, and now the module system.

The natural next steps from here:

Concurrency - goroutines and channels (Go's killer feature)  |  Interfaces - Go's polymorphism model  |  Generics - type parameters added in Go 1.18  |  Testing - go test, table-driven tests, benchmarks

All worthy of their own future course. For now - go build something.

Glossary

TermMeaning
package A directory of .go files that all share the same package <name> declaration. The unit of code organization and visibility in Go.
module A tree of packages versioned and released together. Defined by a go.mod file at the root.
go.mod The module manifest. Contains the module path, minimum Go version, and a list of direct dependencies with pinned versions.
go.sum A lock file of cryptographic hashes for every module version in the dependency graph. Prevents supply-chain tampering.
Exported A name (function, type, field, constant) whose first letter is uppercase. Visible and usable from any package that imports this one.
Unexported A name whose first letter is lowercase. Visible only within the package that defines it. Enforced by the compiler.
Import path The string used in an import statement, e.g. "github.com/spf13/cobra". For your own packages, it is the module path plus the subdirectory path.
Blank import import _ "pkg" - imports a package solely to trigger its init() function. The package's names are not available in the importing file.
Vendor directory A vendor/ directory containing copies of all dependencies. Created with go mod vendor. Used in air-gapped builds.
Internal package A package under an internal/ directory. Go enforces that only code in the same module can import it - external callers get a compile error.
GOPATH A legacy workspace concept from pre-modules Go (before 1.11). All code had to live under $GOPATH/src/. Modules replaced it. You will still see it mentioned in old docs - ignore it for new projects.

Origins, gotchas, and a quick check

Backstory and pitfalls that make this chapter stick.

What's in a name?
package
Unit of code organization. Each Go file declares its package at the top: package mypkg.
module
Collection of packages versioned together. Defined by go.mod. Pre-1.11 Go used GOPATH instead.
go.mod
Module manifest. Lists the module's import path, Go version, and dependencies.
go.sum
Cryptographic checksums of dependencies. Ensures reproducible builds. Commit to git.
import
Brings a package into scope. Path is the module-relative or external URL.
vendor/
Directory of vendored (copied-in) dependencies. Optional in modules era, mandatory in GOPATH era.
GOPATH
Pre-2018 dependency management. Single workspace, all code in $GOPATH/src/<full-import-path>. Effectively deprecated by modules.
semver
Semantic versioning. Go modules requires it: v1.2.3. Major version 2+ requires /v2 in the import path.
Fun fact Go modules (launched in Go 1.11, August 2018) replaced GOPATH and dep. The community had begged for proper dependency management for years - the pre-modules era had GOPATH (single workspace), vendor/ (manual copy), dep (community tool), and various others. Russ Cox's modules design was famously controversial because it rejected dep's design in favor of a "minimum version selection" algorithm. The community grumbled but modules won; today they're universal. The famous Go capitalization-as-export rule: a function Foo is exported (visible to other packages), foo is unexported (package-private). This single character makes the export-rules check trivial - no export keyword needed.
Bug hunt

Two files in the SAME package main. Will this compile?

two-files
// file: greet.go
package main

import "fmt"

type user struct {
    name string
}

func main() {
    u := user{name: "Alice"}
    fmt.Println(greet(u))
}

// file: greeter.go
package main

import "fmt"

func greet(u user) string {
    return fmt.Sprintf("Hello, %s!", u.Name)
}
Click to reveal the bug

NO - compile error: u.Name undefined (type user has no field or method Name).

The struct field is name (lowercase). greet is in the SAME package (main), so it CAN access lowercase (package-private) fields. The actual bug is the typo - u.Name (capitalized) is a different identifier than u.name.

Lesson: capitalization in Go is mechanical and case-sensitive. Name and name are completely different identifiers. Tools like gopls catch this instantly.

Real-world incident The deleted-GitHub-repo build collapse Thousands of CI pipelines broken for hours

In 2018-ish, a small popular Go package was deleted by its author from GitHub. Suddenly thousands of CI pipelines broke - the package was a transitive dependency through several major frameworks. go get failed because the source repo was gone. Builds failed across companies.

The fix: Go's module proxy (proxy.golang.org), which caches every published version forever. As long as the proxy has it, deleted GitHub repos don't break builds. Today, never disable the proxy in production CI.

Lesson: Trust proxy.golang.org. Don't set GOPROXY=direct in CI. For corporate environments, use a private proxy (Athens, JFrog Artifactory, etc.) that mirrors the official proxy.

Quick check

Test yourself

Don't peek. Think first, then click to reveal each answer.

1. In Go, the function Hello (capital H) versus hello (lowercase h):

  • Are aliases for the same function
  • Capital is exported (visible outside the package); lowercase is package-private
  • Capital functions are slower
  • Both are private; need export hello to expose
Show answer
Answer: b. Capitalization is the export rule. Capital = visible to other packages. Lowercase = package-private. This applies to functions, types, fields, methods, variables, constants.

2. The go.mod file lives where?

  • $GOPATH/src/
  • ~/.config/go/modules.json
  • At the root of your module (alongside main.go for a single-module project)
  • In every package directory
Show answer
Answer: c. go.mod is the module root marker. Sub-packages within the module don't have their own go.mod.

3. To upgrade a dependency to its latest minor/patch version:

  • go install latest
  • go get -u <module-path>
  • go mod update
  • Edit go.mod manually
Show answer
Answer: b. go get -u <module-path> upgrades to the latest version. To upgrade to latest of a specific major: go get <module-path>@latest. Minor/patch only: go get -u=patch <module-path>.

4. The go.sum file is:

  • Optional - can be deleted safely
  • A summary of doc comments
  • Cryptographic checksums of every transitive dep version, ensuring reproducible builds
  • Auto-generated test summary
Show answer
Answer: c. go.sum is critical for reproducibility and supply-chain security. Commit it to git alongside go.mod. Deleting it is fine locally (will regenerate) but breaks build reproducibility.

5. A module path of github.com/foo/bar/v2 indicates:

  • Version 2 of the bar package
  • Go modules' explicit major-version rule - v2+ majors require the path to include the major version
  • The 2nd directory under bar
  • A typo
Show answer
Answer: b. Go modules requires /vN in the import path for major versions 2+. This is the "semantic import versioning" rule - v1 and v2 of the same lib are different modules entirely. Lets you import both v1 and v2 of a library in the same program.