How Go organizes code - and why uppercase letters are the access-control system
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.
.go files in the same directory share the same package scope - they can call each other's functions and access each other's types directly, as if they were one big file.package foo and package bar in the same directory is a compile error.package declaration is usually (but not always) the same as the directory name.# 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
// 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"
import. You can't just reach in without asking.
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.
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:
import statements.^1.x" wildcards. Exact versions only.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.
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.
Reconcile go.mod with what your code actually imports.
$ go mod tidy
Adds missing entries, removes unused ones. Run before every commit.
# 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
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
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.
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
}
# 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
// lowercase = hard boundary
func internalHelper() { ... }
// From another package:
import "github.com/x/mymod"
mymod.internalHelper()
// compile error:
// cannot refer to unexported name
// mymod.internalHelper
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.
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
)
| Form | Syntax | When 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. |
import os
from os import path
from os.path import join, exists
import requests as req
# use:
os.getcwd()
req.get("https://...")
import (
"os"
"path/filepath"
req "github.com/go-resty/resty/v2"
)
// use:
os.Getwd()
filepath.Join("a", "b")
req.New().Get("https://...")
import "./utils". Always use the full module-rooted path: import "github.com/you/myapp/utils". This makes imports unambiguous anywhere in the project.
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.
| Package | What it does | Python 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 |
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)
}
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.
| Directory | Purpose | Special 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. |
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.
A typical small HTTP service with cmd/, internal/handlers/, and internal/store/:
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.
Using github.com/spf13/cobra - the library behind kubectl, hugo, and hundreds of other Go CLIs:
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)
}
}
$ go mod init github.com/sunilt/mytool
$ go get github.com/spf13/cobra@v1.7.0
$ go run ./cmd/mytool greet Sunil
Hello, Sunil!
Importing k8s API types and client-go to list pods - the imports themselves illustrate how the k8s module tree is organized:
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.
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.
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.
./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.
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.
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 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.
| Term | Meaning |
|---|---|
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. |
Backstory and pitfalls that make this chapter stick.
package mypkg.go.mod. Pre-1.11 Go used GOPATH instead.$GOPATH/src/<full-import-path>. Effectively deprecated by modules.v1.2.3. Major version 2+ requires /v2 in the import path.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.
Two files in the SAME package main. Will this compile?
// 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)
}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.
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.
Don't peek. Think first, then click to reveal each answer.
1. In Go, the function Hello (capital H) versus hello (lowercase h):
export hello to expose2. The go.mod file lives where?
$GOPATH/src/~/.config/go/modules.jsongo.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 latestgo get -u <module-path>go mod updatego 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:
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:
/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.