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.

  1. Define typesOrder struct with JSON tags; Store interface with Get(ctx, id) (Order, error).
  2. Handler — parse ID from path, call store with r.Context(), map sql.ErrNoRows to 404, other errors to 500.
  3. Serverhttp.Server{Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5s}; register /healthz returning 200 and /orders/{id}.
  4. Shutdown — trap signal, server.Shutdown(ctx) with 30-second drain window so in-flight requests finish.
  5. Deploy — multi-stage Dockerfile: go build -ldflags="-s -w" in builder stage, copy static binary to scratch or distroless image (~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

NeedPrefer GoConsider instead
High-concurrency HTTP/gRPC APIYes — goroutines + static binaryNode.js if team is JS-only
ML training / notebooksNoPython
Memory-safe systems / WASM / on-chainNoRust
Rapid CRUD prototypeMaybePython (Django/FastAPI) or Node
K8s operator / CLI toolYes — ecosystem standardRust for max performance
CPU-heavy numeric simulationMaybeRust, C++, or Python + NumPy
Small container, fast startupYesRust (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 for loops 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.Server without 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.mod and CI; use the same version in Docker builder images.
  • Run go test -race ./... and go vet ./... on every pull request.
  • Set HTTP server read/write/idle timeouts; propagate context to all I/O.
  • Structure logs as JSON with request IDs; export Prometheus metrics on /metrics.
  • Build static binaries with CGO_ENABLED=0 for portable containers unless you need C bindings.
  • Scan modules with govulncheck or 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 doc comments; keep internal/ for implementation details.
  • Load-test with realistic concurrency before claiming your service handles 10k RPS.
  • Profile with pprof when 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 context for cancellation.
  • net/http + testing are production-ready; the ecosystem fills gaps for routing, ORMs, and observability.

Related reading