Guide
Go (Golang) fundamentals explained
Go (often called Golang) is a statically typed,
compiled language designed at Google for building reliable, efficient software at
scale. Its signature features are a deliberately small language spec, fast compile
times, garbage-collected memory, and first-class concurrency via
goroutines and channels. Go compiles to a single static binary with no runtime
dependency — ideal for
container images,
CLI tools, and network services that must start instantly and run for months
without leaking memory. Kubernetes, Docker, Prometheus, Terraform’s core, and
countless fintech and infrastructure APIs are written in Go. This guide covers
packages and modules, structs and interfaces, goroutines and channels, Go’s
explicit error-handling idiom, the context package, building HTTP
services with net/http, testing, a worked REST API example, a
language-selection decision table, common pitfalls, and a production checklist.
What Go is (and where it fits)
Go sits between scripting languages and systems languages: faster than
Python or
Node.js on CPU-bound
work, simpler than
Rust for teams that
want garbage collection and a gentler learning curve. The compiler produces
native machine code; cross-compilation to Linux, macOS, and Windows from one
machine is a one-line GOOS/GOARCH environment variable
change.
Strong fits: REST and gRPC microservices, API gateways, workers that consume from Kafka or message queues, DevOps tooling, blockchain nodes and indexers, and any service deployed to Kubernetes where a small image and low memory footprint matter.
Weaker fits: Hard real-time systems, browser frontends, heavy numeric/scientific computing (no NumPy equivalent in stdlib), and domains where Rust’s zero-cost abstractions or Python’s ML ecosystem are non-negotiable.
Packages, modules, and project layout
Every Go file starts with package main (an executable) or another
package name (a library). Imports are grouped: standard library first, then
third-party modules. Since Go 1.11, modules replace the old
GOPATH workspace: a go.mod file at the repo root
declares the module path and dependency versions; go.sum pins
cryptographic hashes for reproducible builds.
module github.com/acme/orders
go 1.22
require (
github.com/jackc/pgx/v5 v5.5.0
)
Conventional layout: cmd/server/main.go for entry points,
internal/ for packages not importable by outsiders,
pkg/ for shared public libraries. Run go mod tidy after
adding imports; commit both go.mod and go.sum to version
control.
Types, structs, and methods
Go has primitives (int, float64, bool,
string), arrays, slices (dynamic views), maps, pointers, and
structs for grouping fields. Methods attach to a type via a
receiver — value receivers copy; pointer receivers mutate and avoid copying
large structs.
type Order struct {
ID string
Amount int64 // cents
Paid bool
}
func (o *Order) MarkPaid() {
o.Paid = true
}
Slices and maps are reference types backed by runtime structures; copying a slice
header shares the underlying array until you append beyond capacity.
Use make with an initial capacity when you know approximate size to
avoid repeated reallocations.
Zero values and composition
Uninitialized variables get zero values (0,
false, "", nil) — no undefined behavior.
Embed structs to compose behavior without inheritance; promoted fields and methods
flatten into the outer type.
Interfaces: implicit satisfaction
Go interfaces define method sets; a type satisfies an interface by implementing
those methods — no implements keyword. The empty interface
interface{} (or any since Go 1.18) accepts any value;
prefer concrete types or small, focused interfaces in production code.
type Payer interface {
Pay(cents int64) error
}
func Settle(p Payer, amount int64) error {
return p.Pay(amount)
}
The standard library’s io.Reader and io.Writer
interfaces power most I/O — files, network connections, HTTP bodies, and test
doubles all plug in through the same abstraction. Keep interfaces defined by the
consumer, not the producer, to avoid bloated contracts.
Error handling: explicit, not exceptional
Go has no exceptions. Functions return (T, error); callers check
if err != nil immediately. Wrap errors with context using
fmt.Errorf("fetch order %s: %w", id, err) and unwrap with
errors.Is / errors.As for typed handling.
panic exists for truly unrecoverable programmer bugs; recover it only
at goroutine boundaries (e.g. HTTP middleware) so one bad request does not crash
the process. Libraries should return errors; applications may log and continue.
For HTTP APIs, map domain errors to status codes in one place — do not leak internal error strings to clients. Pair with structured logging so every error carries request IDs and trace spans.
Goroutines, channels, and the concurrency model
A goroutine is a lightweight thread scheduled by the Go runtime
on OS threads (M:N model). Start one with go doWork() — thousands
cost kilobytes of stack, not megabytes. Communicate through
channels (chan T) rather than shared mutable state
when possible: “Do not communicate by sharing memory; share memory by
communicating.”
results := make(chan int, 10) // buffered
go func() { results <- compute() }()
value := <-results
Patterns you will use daily
- Worker pools — fixed goroutines reading jobs from a channel.
- fan-out / fan-in — parallelize work, merge results on one channel.
- select — multiplex channel operations and timeouts.
- sync.WaitGroup — wait for a batch of goroutines to finish.
- sync.Mutex — protect shared maps and counters when channels are awkward.
Always bound concurrency: unbounded go calls under load exhaust file
descriptors and memory. Use semaphores (buffered channel of tokens) or worker
pool sizes tied to CPU and downstream limits.
Context: cancellation and deadlines
The context.Context package propagates deadlines, cancellation signals,
and request-scoped values down call stacks. Every HTTP handler receives
r.Context(); pass it to database queries and outbound RPCs so clients
disconnecting mid-request stop burning resources.
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
row := db.QueryRowContext(ctx, "SELECT ...")
Never store a Context in a struct field — pass it as the first function argument.
Use context.Background() only at program roots and tests.
HTTP services and JSON
net/http is production-grade: http.Server with timeouts
(ReadHeaderTimeout, IdleTimeout), middleware via function
wrapping, and httptest for integration tests. Encode JSON with
encoding/json — struct tags control field names;
json.NewEncoder(w).Encode(v) streams responses.
For larger APIs, frameworks like chi, echo, or
gin add routing and middleware; the stdlib remains the foundation.
Expose metrics for
observability
and put TLS termination behind a
reverse proxy or
service mesh in production.
For service-to-service calls, pair HTTP with gRPC and Protocol Buffers when you need strongly typed contracts and streaming.
Testing with go test
Tests live in _test.go files beside the code they exercise.
go test ./... runs the suite; -race enables the race
detector (run in CI on every commit). Table-driven tests loop over input/output
cases — idiomatic and concise.
testing.T for unit tests, testing.B for benchmarks,
httptest.NewRecorder for handler tests without binding a port.
Fuzzing (go test -fuzz) finds edge cases in parsers and decoders.
Aim for fast unit tests and a smaller set of integration tests against real
PostgreSQL
via testcontainers or ephemeral databases.
Worked example: minimal order API
A team ships an internal order-status API. Requirements: JSON in/out, 2-second timeout per request, graceful shutdown on SIGTERM, health check for Kubernetes.
- Define types —
Orderstruct with JSON tags;Storeinterface withGet(ctx, id) (Order, error). - Handler — parse ID from path, call store with
r.Context(), mapsql.ErrNoRowsto 404, other errors to 500. - Server —
http.Server{Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5s}; register/healthzreturning 200 and/orders/{id}. - Shutdown — trap signal,
server.Shutdown(ctx)with 30-second drain window so in-flight requests finish. - Deploy — multi-stage Dockerfile:
go build -ldflags="-s -w"in builder stage, copy static binary toscratchordistrolessimage (~10 MB total).
Result: one binary, sub-50 ms cold start, trivial horizontal scaling behind a load balancer, and clear seams for swapping the in-memory store with Postgres without rewriting handlers.
Language decision table
| Need | Prefer Go | Consider instead |
|---|---|---|
| High-concurrency HTTP/gRPC API | Yes — goroutines + static binary | Node.js if team is JS-only |
| ML training / notebooks | No | Python |
| Memory-safe systems / WASM / on-chain | No | Rust |
| Rapid CRUD prototype | Maybe | Python (Django/FastAPI) or Node |
| K8s operator / CLI tool | Yes — ecosystem standard | Rust for max performance |
| CPU-heavy numeric simulation | Maybe | Rust, C++, or Python + NumPy |
| Small container, fast startup | Yes | Rust (smaller but slower compile) |
Common pitfalls and how to avoid them
- Goroutine leaks — blocked sends/receives with no receiver; always pair with context cancellation or timeouts.
- Loop variable capture — before Go 1.22, closure in
forloops captured the same variable; use a local copy or upgrade. - Nil interface traps — a typed nil pointer stored in an interface is not equal to untyped
nil; check carefully in error returns. - Ignoring errors —
_, _ = db.Exec(...)hides failures; at minimum log wrapped errors. - Global mutable state — package-level maps without mutexes race under concurrent HTTP handlers.
- Missing server timeouts — default
http.Serverwithout timeouts enables slowloris and hung connections. - Over-using
interface{}— loses compile-time checks; generics (Go 1.18+) help for containers and helpers.
Production checklist
- Pin Go version in
go.modand CI; use the same version in Docker builder images. - Run
go test -race ./...andgo vet ./...on every pull request. - Set HTTP server read/write/idle timeouts; propagate
contextto all I/O. - Structure logs as JSON with request IDs; export Prometheus metrics on
/metrics. - Build static binaries with
CGO_ENABLED=0for portable containers unless you need C bindings. - Scan modules with
govulncheckor Dependabot; Go’s minimal stdlib reduces but does not eliminate supply-chain risk. - Implement graceful shutdown — drain connections before exit on SIGTERM in Kubernetes.
- Document public packages with
go doccomments; keepinternal/for implementation details. - Load-test with realistic concurrency before claiming your service handles 10k RPS.
- Profile with
pprofwhen CPU or heap growth surprises you — guessing beats measuring only in meetings.
Key takeaways
- Go optimizes for simplicity, fast builds, and built-in concurrency — not language maximalism.
- Modules (
go.mod) manage dependencies; static binaries simplify deployment. - Interfaces are satisfied implicitly; small interfaces compose cleanly.
- Errors are values — check them, wrap them, never ignore them silently.
- Goroutines + channels scale I/O-bound services; bound concurrency and use
contextfor cancellation. - net/http + testing are production-ready; the ecosystem fills gaps for routing, ORMs, and observability.
Related reading
- Microservices architecture explained — service boundaries, APIs, and where Go backends fit
- Docker fundamentals explained — packaging Go binaries into minimal container images
- Kubernetes fundamentals explained — deploying and scaling Go services in production
- Rust fundamentals explained — when to reach for memory safety without a garbage collector