Golang / Golang Internals and Memory Management Interview Questions
A Go array is a fixed-length, value-type sequence of elements stored contiguously in memory. Its length is part of its type: [5]int and [6]int are distinct types. Arrays are copied entirely when passed to functions.
A Go slice is a lightweight descriptor — a three-field struct that lives on the stack and points into an underlying array on the heap. The three fields are:
| Field | Type | Meaning |
|---|---|---|
| ptr | unsafe.Pointer | Pointer to the first element of the backing array |
| len | int | Number of elements accessible through the slice |
| cap | int | Total elements in the backing array from ptr onward |
// Array — fixed, value type
arr := [5]int{1, 2, 3, 4, 5}
arrCopy := arr // full copy of all 5 ints
// Slice — descriptor pointing to arr's backing storage
s := arr[1:4] // s.ptr = &arr[1], s.len = 3, s.cap = 4
fmt.Println(len(s), cap(s)) // 3 4
// Modifying through the slice modifies the backing array
s[0] = 99
fmt.Println(arr) // [1 99 3 4 5] — arr is mutated!
// Make allocates a fresh backing array
fresh := make([]int, 3, 6) // len=3, cap=6, new backing array
// Nil slice — zero value; all three fields are zero
var nilSlice []int
fmt.Println(nilSlice == nil, len(nilSlice)) // true 0Because a slice is just a small header, passing a slice to a function is cheap — only the three-field struct is copied, not the underlying array. However, this means the function sees the same backing array and can mutate its elements. To avoid unintended sharing, use copy() or append() on a new slice.
append(s, elems...) adds elements to slice s. The critical behaviour depends on whether the backing array has spare capacity:
- If
len(s) + len(elems) <= cap(s): no new allocation. The elements are written directly into the existing backing array beyonds.len. The returned slice shares the same backing array assbut with an incrementedlen. - If capacity is exhausted: Go allocates a new, larger backing array, copies all existing elements, then appends the new ones. The returned slice points to the new array; the original backing array is now unreferenced (and eligible for GC).
s := make([]int, 3, 5) // len=3 cap=5 — room for 2 more
s2 := append(s, 10) // fits in cap — no reallocation
// s and s2 SHARE the backing array until cap is exceeded
s3 := append(s2, 20, 30) // cap exceeded — new backing array allocated
// s, s2 still point to OLD array; s3 points to NEW array
// ALWAYS use the returned value of append
s = append(s, 99) // wrong to ignore the return — s might be outdated
// Growth strategy (Go 1.18+)
// cap < 256: double (newcap = oldcap * 2)
// cap >= 256: grow ~25% + smooth correction to avoid thrashing
// Pre-allocate when the final size is known
names := make([]string, 0, 1000) // avoids N reallocations in a loop
for _, n := range rawNames {
names = append(names, n)
}The growth strategy changed in Go 1.18 from a simple doubling to a smoother formula: small slices (cap < 256) still double; larger slices grow by about 25% with a correction that blends the doubling and 25% rates. This avoids the cliff-edge behaviour at the transition point.
Hidden sharing trap: if you append to a sub-slice that still has spare capacity, the write goes into the original backing array, silently overwriting data seen by other slices sharing that array. Always use the three-index slice s[lo:hi:hi] to set cap equal to len when you want to guarantee a fresh allocation on the next append.
Go does not expose manual heap allocation. Instead, the compiler uses escape analysis to decide, at compile time, whether each variable can live on the current goroutine's stack or must be moved (escape) to the heap.
| Aspect | Stack | Heap |
|---|---|---|
| Lifetime | Function frame — freed on return | Until GC collects (no references) |
| Allocation cost | Near zero (pointer bump) | GC overhead, malloc-like |
| GC involvement | None | Tracked by GC tri-color marking |
| Access speed | Fastest (CPU cache friendly) | Slightly slower (pointer indirection) |
| Size | Default 2 KB, grows dynamically to 1 GB | Limited only by available RAM |
// Does NOT escape — lives on stack (no reference escapes the function)
func sum(a, b int) int {
result := a + b // stays on stack
return result
}
// DOES escape — caller holds a pointer; Go must allocate on heap
func newCounter() *int {
n := 0 // n escapes to heap — pointer outlives function frame
return &n
}
// Escape via interface — any value stored in an interface box escapes
func logValue(v interface{}) { fmt.Println(v) }
x := 42
logValue(x) // x's copy escapes to heap because interface requires a pointer
// Slice backing arrays that are too large — compiler heuristic
big := make([]byte, 64*1024) // large allocation always goes to heap
// Inspect escape analysis
// go build -gcflags="-m" ./...
// go build -gcflags="-m -m" ./... (verbose, shows reason)
// Output lines like: './main.go:8:2: n escapes to heap'Variables escape to the heap in the following common situations: the address is returned from the function; the variable is stored in a heap-allocated data structure (map, slice, interface); a closure captures it by reference; or the compiler's heuristics decide the stack is too small. Unnecessary heap allocations increase GC pressure — a hot path that constantly allocates small objects is a primary cause of GC-induced latency spikes.
Every goroutine starts with a small stack — only 2 KB by default (as of Go 1.4). This is orders of magnitude smaller than an OS thread's typical 1–8 MB stack, which is why Go can run millions of goroutines concurrently.
Go uses a copy-on-grow (contiguous stack) strategy. When the runtime detects that the current frame needs more space than available (a stack-overflow check inserted at function entry points), it:
- Allocates a new, larger stack (typically double the current size).
- Copies the entire old stack to the new location.
- Updates all pointers that referred into the old stack (pointer fixup).
- Frees the old stack.
// Goroutines start with 2 KB stack — very cheap to spawn
for i := 0; i < 1_000_000; i++ {
go func(id int) {
// Each goroutine starts with a 2 KB stack
doWork(id)
}(i)
}
// Deeply recursive function — stack grows automatically
func fib(n int) int {
if n <= 1 { return n }
return fib(n-1) + fib(n-2) // stack grows as needed, up to GOMAXSTACKS
}
// Maximum goroutine stack size (configurable via GOTRACEBACK env)
// Default maximum is 1 GB
// Stack size at goroutine creation is visible in stack traces:
// goroutine 1 [running]:
// main.main()
// /path/main.go:10 +0x... [stack: 2048]
// runtime.Stack() for diagnostics
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true) // all goroutines
fmt.Printf("%s", buf[:n])The older segmented stack approach (Go 1.3 and earlier) allocated stack segments as a linked list. It was abandoned because of the hot-split problem: a function call at the exact segment boundary caused repeated segment allocation and deallocation in a tight loop, causing up to a 10× performance regression. The current contiguous copy model has no such problem, though it does spend time on the copy when growth is needed.
Go uses a concurrent, tri-color mark-and-sweep garbage collector. The key design goal is to minimize Stop-The-World (STW) pauses to sub-millisecond levels, even on large heaps, allowing Go programs to remain responsive under continuous allocation pressure.
| Color | Meaning |
|---|---|
| White | Not yet visited. At GC end, white objects are unreachable — will be freed. |
| Grey | Reachable from a root, but outgoing references not yet scanned. |
| Black | Fully scanned. All references from this object have been processed. |
Algorithm phases:
- STW start (~100 µs): pauses all goroutines briefly to take a consistent root snapshot and enable the write barrier.
- Concurrent mark: GC goroutines run alongside application goroutines, turning grey objects black by scanning their references. Application goroutines continue executing.
- Write barrier: while marking is concurrent, the program may create new pointers. The write barrier (Dijkstra/hybrid) intercepts pointer writes and shades the pointed-to object grey so it is not missed.
- Mark termination (STW) (~100 µs): a second brief pause to drain the grey queue and disable the write barrier.
- Concurrent sweep: white (unreachable) objects are swept back into free lists. This is concurrent — no STW needed.
// Trigger GC manually (rarely needed in production)
import "runtime"
runtime.GC() // triggers a full GC cycle
// Read GC stats
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
fmt.Printf("NumGC: %d\n", stats.NumGC)
fmt.Printf("PauseTotal: %v\n", time.Duration(stats.PauseTotalNs))
fmt.Printf("HeapAlloc: %d MB\n", stats.HeapAlloc/1024/1024)
// GOGC env — controls GC frequency
// GOGC=100 (default): GC triggers when heap grows 100% above last GC live set
// GOGC=200: GC triggers at 200% — less frequent GC, more memory used
// GOGC=off: disables GC (only for benchmarks)
// GOMEMLIMIT (Go 1.19+) — hard memory limit
// GOMEMLIMIT=500MiB triggers GC more aggressively if heap approaches 500 MBThe write barrier overhead (~5–10% CPU in allocation-heavy code) is the price paid for concurrent marking. In Go 1.22+, the runtime adaptively adjusts GC frequency using GOGC together with GOMEMLIMIT to balance latency and throughput automatically.
Go's GC is controlled by two primary knobs: GOGC (the classic throughput knob) and GOMEMLIMIT (the memory ceiling introduced in Go 1.19).
| Variable | Default | Meaning |
|---|---|---|
| GOGC | 100 | GC triggers when live heap grows by GOGC% since last GC |
| GOMEMLIMIT | math.MaxInt64 (off) | Hard memory limit; GC runs more aggressively when heap approaches this |
// GOGC examples (set as environment variable or via runtime/debug)
// GOGC=100 (default) — GC when heap is 2x last-collection live set
// GOGC=200 — GC when heap is 3x — fewer GCs, more memory
// GOGC=50 — GC when heap is 1.5x — more frequent, lower peak
// GOGC=off — disable GC (benchmarks/short programs only)
import "runtime/debug"
// Set programmatically (returns old value)
oldGOGC := debug.SetGCPercent(200) // increase to reduce GC frequency
defer debug.SetGCPercent(oldGOGC)
// GOMEMLIMIT — prevents OOM by forcing GC before memory is exhausted
// GOMEMLIMIT=500MiB
debug.SetMemoryLimit(500 * 1024 * 1024) // 500 MB hard limit
// Best practice for containerized Go services:
// Set GOMEMLIMIT to ~90% of container memory limit
// This prevents OOM kills while allowing GC to breathe
// Example: container limit 1 GiB → GOMEMLIMIT=900MiB
// Runtime metrics (Go 1.16+)
import "runtime/metrics"
samples := []metrics.Sample{
{Name: "/gc/cycles/total:gc-cycles"},
{Name: "/memory/classes/heap/objects:bytes"},
}
metrics.Read(samples)
fmt.Println(samples[0].Value.Uint64()) // total GC cyclesThe interaction between the two: if GOGC=100 would trigger GC at 2 GB but GOMEMLIMIT=1.5 GB, the runtime will trigger GC earlier to stay under the limit. This makes containerised deployments safer — previously, a spike in allocations could cause an OOM kill before GC had a chance to run.
Go's built-in map is a hash table composed of an array of buckets. Each bucket holds up to 8 key-value pairs and a compact bitmask (the tophash) of the top 8 bits of each key's hash — used for quick equality rejection without a full key comparison.
// Map creation
m := make(map[string]int) // empty, grows as needed
m2 := make(map[string]int, 100) // hint: pre-allocate for ~100 entries
// The hint avoids rehashing during initial population
// Map automatically grows (rehash) when load factor ~6.5 entries/bucket
// Comma-ok idiom — distinguish missing key from zero value
m["alice"] = 0
val, ok := m["alice"] // val=0, ok=true — key exists
val, ok = m["bob"] // val=0, ok=false — key absent
if !ok { fmt.Println("key not found") }
// Deleting a key
delete(m, "alice") // no-op if key absent, no panic
// Iteration order is intentionally randomised
for k, v := range m {
fmt.Println(k, v) // order differs across runs
}
// Maps are NOT thread-safe — concurrent reads+writes cause a fatal error
// Detected by the race detector: go run -race ./...
// Safe alternatives:
// 1. sync.Mutex protecting the map
// 2. sync.RWMutex for read-heavy workloads
// 3. sync.Map (optimised for high-concurrency, low-write scenarios)| Concept | Detail |
|---|---|
| Bucket size | 8 key-value pairs per bucket |
| Tophash | Top 8 bits of hash stored per slot for fast rejection |
| Load factor | Rehash triggered at ~6.5 entries/bucket (overflow buckets used before) |
| Iteration order | Randomised per run — the runtime deliberately adds randomisation |
| Thread safety | Not thread-safe — use sync.Mutex, sync.RWMutex, or sync.Map |
| Key requirement | Keys must be comparable (==); slices, maps, functions cannot be keys |
sync.Map (Go 1.9+) is a specialised concurrent map optimised for specific access patterns. It is not a general-purpose replacement for map + sync.Mutex.
| Aspect | map + sync.Mutex | sync.Map |
|---|---|---|
| API | Standard map syntax | Load, Store, LoadOrStore, Delete, Range |
| Ideal workload | General purpose, write-heavy | Read-heavy, mostly stable key sets |
| Performance on reads | Good (RWMutex) | Excellent — reads often lock-free |
| Performance on writes | Good | Slower — write path is complex |
| Key types | Any comparable | interface{} (loses type safety) |
| Zero value | Must initialise map first | Ready to use as zero value |
// sync.Map — optimised for: once written, many reads
var sm sync.Map
// Store
sm.Store("key", 42)
// Load
val, ok := sm.Load("key")
if ok {
fmt.Println(val.(int)) // type assertion required
}
// LoadOrStore — atomic get-or-set
actual, loaded := sm.LoadOrStore("key", 99)
fmt.Println(actual, loaded) // 42 true (existing value returned)
// Range — iterate (snapshot semantics during iteration not guaranteed)
sm.Range(func(k, v any) bool {
fmt.Println(k, v)
return true // return false to stop iteration
})
// mutex-protected map — preferred for write-heavy or type-safe needs
type SafeMap[K comparable, V any] struct {
mu sync.RWMutex
m map[K]V
}
func (s *SafeMap[K, V]) Get(k K) (V, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
v, ok := s.m[k]
return v, ok
}sync.Map uses two internal maps: a read-only map (accessed without locks using atomic loads) and a dirty map (accessed under a mutex) for new and updated keys. A key migrates from dirty to read when enough dirty reads occur. This optimises read performance but makes writes heavier. Use it when: caches with stable keys (add once, read many), or when goroutines read disjoint key sets.
Go is pass-by-value: every function argument is a copy. For types that are large or need to be mutated by the called function, passing a pointer avoids the copy and allows in-place modification. Understanding when to use pointers is essential for both correctness and performance.
// Pass by VALUE — mutation inside the function is invisible to caller
func doubleValue(n int) { n *= 2 } // n is a copy
x := 5
doubleValue(x)
fmt.Println(x) // still 5
// Pass by POINTER — mutation is visible
func doublePtr(n *int) { *n *= 2 }
doublePtr(&x)
fmt.Println(x) // 10
// Large structs — avoid copying by passing pointer
type BigStruct struct { data [1024]byte }
func processValue(b BigStruct) {} // copies 1 KB every call
func processPtr(b *BigStruct) {} // copies only 8 bytes (pointer)
// Value receiver vs pointer receiver on methods
type Counter struct{ count int }
func (c Counter) Get() int { return c.count } // value receiver — copy
func (c *Counter) Inc() { c.count++ } // pointer receiver — mutates
c := Counter{}
c.Inc() // Go automatically takes &c
fmt.Println(c.Get()) // 1
// Pointer vs nil — always check before dereferencing
var p *int
fmt.Println(p) //
// fmt.Println(*p) // PANIC: nil pointer dereference | Use pointer when... | Use value when... |
|---|---|
| The function must mutate the receiver/argument | The function is read-only |
| The type is large (>64 bytes typical threshold) | The type is small (int, bool, small struct) |
| The type has a sync.Mutex or similar non-copyable field | Immutability is desirable |
| Consistency: other methods use pointer receiver | Type is inherently value-like (time.Time, net/netip.Addr) |
Go uses a cooperative/preemptive M:N scheduler — M goroutines multiplexed onto N OS threads, where N defaults to GOMAXPROCS (number of logical CPUs). The scheduler uses three key entities:
| Entity | Symbol | Description |
|---|---|---|
| Goroutine | G | The logical unit of work — a user-space green thread with its own stack |
| Machine (OS thread) | M | An OS thread that executes Go code; managed by the runtime |
| Processor | P | A logical CPU context; holds a local run queue of goroutines waiting to run |
// GOMAXPROCS controls the number of P's (and thus OS threads active)
import "runtime"
runtime.GOMAXPROCS(4) // use 4 OS threads for parallel execution
fmt.Println(runtime.GOMAXPROCS(0)) // 0 = query without changing
// Scheduler events that cause a context switch:
// 1. Goroutine blocks on channel send/receive
// 2. Goroutine blocks on system call (M parks, P finds another M)
// 3. go statement (new G added to local run queue of current P)
// 4. runtime.Gosched() — voluntary yield
// 5. Function call (Go 1.14+: asynchronous preemption via signals)
// Work stealing: when P's local queue is empty,
// it steals half the goroutines from another P's queue
// Global run queue: accessed when local queue has > 256 Gs,
// or periodically to ensure fairness
// View scheduler decisions
// GOTRACE=scheduler ./myapp — not a real flag, but:
// go tool trace trace.out — after: f, _ := os.Create("trace.out")
// trace.Start(f); ...; trace.Stop()System call handling: When a goroutine makes a blocking system call (e.g., reading a file), the M detaches from its P. The P then attaches to another idle M (or creates a new one), so other goroutines continue running. When the system call returns, the original M tries to reacquire a P; if none is available, it parks and the goroutine goes to the global queue.
Asynchronous preemption (Go 1.14+): the runtime sends SIGURG signals to goroutines that have run too long without a function call, forcing a context switch. This prevents a CPU-intensive goroutine from starving others even without cooperative yield points.
A channel is a typed, goroutine-safe FIFO queue managed by the runtime. Internally it is a hchan struct containing a circular ring buffer (for buffered channels), send and receive queues of waiting goroutines, a mutex, and metadata like element type, capacity, and current length.
// Unbuffered channel — synchronous rendezvous
ch := make(chan int) // cap=0, buf=nil
// Send blocks until a goroutine receives; receive blocks until send
// Buffered channel — asynchronous up to capacity
ch2 := make(chan int, 5) // cap=5, ring buffer of 5 ints
ch2 <- 1 // does not block (buffer not full)
ch2 <- 2
v := <-ch2 // 1 (FIFO)
// Select — multiplexed channel operations
select {
case msg := <-ch:
fmt.Println("received", msg)
case ch2 <- 42:
fmt.Println("sent 42")
case <-time.After(1 * time.Second):
fmt.Println("timeout")
default:
fmt.Println("non-blocking — no case ready")
}
// Closing a channel — signals receivers: no more data
close(ch2)
val, ok := <-ch2 // ok=false means channel closed and empty
// Range over channel — reads until closed
for v := range ch2 {
fmt.Println(v)
}
// Common rules:
// - Sending to a closed channel panics
// - Receiving from a closed, empty channel returns zero value, ok=false
// - Closing a nil channel panics
// - Only the SENDER should close (receiver cannot know when sender is done)Goroutine parking: when a send finds the buffer full (or an unbuffered channel has no receiver), the goroutine is added to the sendq (a linked list of goroutines in the hchan) and parked. When a receiver arrives, it directly copies the data from the parked sender's stack — a zero-copy optimisation for unbuffered channels — and unparks the sender goroutine.
A Go interface value is a two-word struct: a type pointer and a data pointer. There are two variants in the runtime: iface (for interfaces with methods — has a pointer to the method table / itab) and eface (for the empty interface any — just type and data pointers).
// Empty interface (any / interface{})
// eface { type *_type, data unsafe.Pointer }
var v any = 42
// _type = pointer to int's type descriptor
// data = pointer to a heap-allocated int holding 42
// Interface with methods
// iface { itab *itab, data unsafe.Pointer }
// itab = {inter *interfacetype, type *_type, hash uint32, fun [...]uintptr}
// fun[] = the method dispatch table (virtual call table)
type Stringer interface { String() string }
type MyInt int
func (m MyInt) String() string { return fmt.Sprintf("%d", m) }
var s Stringer = MyInt(5) // iface{itab=*MyInt-Stringer-itab, data=ptr-to-5}
fmt.Println(s.String()) // dispatches via itab.fun[0]
// Type assertion — recovers the concrete type
if mi, ok := s.(MyInt); ok {
fmt.Println(mi + 1)
}
// Type switch
switch t := v.(type) {
case int: fmt.Println("int:", t)
case string: fmt.Println("string:", t)
default: fmt.Println("unknown")
}
// Performance implication: values stored in interface often escape to heap
// Small values (pointer-sized or smaller) can be inlined into the data word
// Avoid interface{} in hot paths — use generics (Go 1.18+) insteadInterface comparison: two interface values are equal if and only if their type pointers are equal (same concrete type) and their data pointers point to equal values. Comparing interfaces with non-comparable concrete types (e.g., slices) panics at runtime.
Nil interface pitfall: (*MyType)(nil) stored in an interface is not a nil interface — the interface has a non-nil type pointer. Always return a nil interface (return nil) rather than a typed nil pointer when returning an interface from a function.
Go uses a hierarchical, size-class-based allocator inspired by TCMalloc (Thread-Caching Malloc). The three levels minimise lock contention and fragmentation.
| Layer | Scope | Locking | Purpose |
|---|---|---|---|
| mcache | Per-P (per logical CPU) | Lock-free | Per-CPU cache of spans for each size class — fast path |
| mcentral | Per size class, global | Mutex per size class | Central pool of spans; supplies mcache when empty |
| mheap | Global | Mutex (large objects) | OS memory; supplies mcentral with new spans; manages large (>32 KB) allocations directly |
// Allocation path for a small object (<=32 KB):
// 1. Round up to nearest size class (e.g., 24 bytes → 32-byte class)
// 2. Check P's mcache for that size class — if span available, use it (no lock)
// 3. If mcache empty, fetch a span from mcentral (mutex on that size class)
// 4. If mcentral empty, request memory from mheap (global lock)
// 5. If mheap exhausted, request OS memory via mmap/VirtualAlloc
// Large object allocation (>32 KB):
// Directly from mheap — allocated as a multi-page span
// Tiny allocation (<=16 bytes, no pointers, not zero-sized):
// Multiple tiny objects packed into a single 16-byte slot
// e.g., allocating many bool or small int values is very cheap
// Size classes — Go has ~68 size classes:
// 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, ... 32768 bytes
// Inspecting allocation stats
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Alloc = %d KB\n", m.Alloc/1024)
fmt.Printf("TotalAlloc = %d KB\n", m.TotalAlloc/1024)
fmt.Printf("Sys = %d KB\n", m.Sys/1024)
fmt.Printf("Mallocs = %d\n", m.Mallocs)
fmt.Printf("Frees = %d\n", m.Frees)The size-class design eliminates fragmentation for small objects: each span serves exactly one size class, so all objects in a span are the same size and fit perfectly. This avoids the fragmentation of a general-purpose allocator.
A Go string is a two-word struct similar to a slice header but without a capacity field: a ptr (unsafe.Pointer to the UTF-8 bytes) and a len (byte count). Strings are immutable — the bytes they point to cannot be modified through any string operation.
// String header: {ptr unsafe.Pointer, len int}
s := "hello"
fmt.Println(len(s)) // 5 (bytes, not runes)
fmt.Println(s[0]) // 104 — byte value of 'h'
// s[0] = 'H' // compile error: cannot assign to s[0]
// UTF-8: len counts bytes, range counts runes
emoji := "Go 🚀"
fmt.Println(len(emoji)) // 7 (G=1, o=1, space=1, rocket=4 bytes)
for i, r := range emoji {
fmt.Printf("%d: %c\n", i, r) // i is byte offset, r is rune value
}
// String concatenation creates a NEW backing array each time
// Avoid in loops — use strings.Builder instead
var b strings.Builder
for _, s := range words {
b.WriteString(s)
b.WriteByte(' ')
}
result := b.String() // single allocation
// string <-> []byte conversion
// Each conversion copies the bytes (different backing arrays)
bytes := []byte(s) // copy into a new mutable byte slice
s2 := string(bytes) // copy back — new immutable string
// Zero-copy cast with unsafe (avoid unless in hot path + well-understood)
// Not recommended for general useImmutability allows strings to be safely shared without copying — multiple strings can point to the same backing byte array (e.g., a slice of a longer string). String constants are interned into read-only memory in the binary, so no heap allocation is needed for them. The strings.Builder type avoids repeated allocation by maintaining a []byte buffer and converting to string only at the end.
defer schedules a function call to run when the surrounding function returns — whether normally or via panic. The deferred call's arguments are evaluated immediately when the defer statement is executed, not when the deferred function runs.
// Arguments evaluated immediately at defer statement
x := 10
defer fmt.Println(x) // prints 10 even if x is later changed
x = 20
// Output: 10
// Named return values + defer — modify return before it reaches caller
func divide(a, b float64) (result float64, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
result = a / b
return // named return
}
// LIFO order — multiple defers run last-in first-out
func cleanup() {
defer fmt.Println("first defer")
defer fmt.Println("second defer")
defer fmt.Println("third defer")
// Output: third, second, first
}
// Performance: defer has overhead (allocates defer record pre-Go 1.14)
// Go 1.14+ inlines simple defers — near-zero overhead for non-looping, non-panic paths
// Avoid defer in tight loops — call cleanup explicitly
func processFiles(files []string) {
for _, f := range files {
func() { // wrap in closure so defer fires per file
fh, _ := os.Open(f)
defer fh.Close() // OK inside the per-file closure
// process...
}()
}
}Internal representation: in Go 1.14+, the compiler classifies defers as open-coded (inlined when statically determinable), stack-allocated, or heap-allocated. Open-coded defers have near-zero overhead — the compiler emits the deferred code at each return site. Only defers inside loops or under conditions that can vary at runtime fall back to the slower heap-allocated path.
panic stops the normal execution of the current goroutine, unwinds the stack calling all deferred functions, and propagates up until it reaches the top of the goroutine's stack — at which point the runtime prints a stack trace and terminates the program. recover can intercept a panic but only inside a deferred function.
// Basic panic — causes runtime abort with stack trace
func mustPositive(n int) int {
if n <= 0 {
panic(fmt.Sprintf("expected positive, got %d", n))
}
return n
}
// recover — MUST be called inside a deferred function
func safeDiv(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered from panic: %v", r)
}
}()
result = a / b // panics if b == 0
return
}
r, err := safeDiv(10, 0)
fmt.Println(r, err) // 0 recovered from panic: runtime error: integer divide by zero
// recover() returns nil if not panicking
// It cannot recover a panic from a DIFFERENT goroutine
go func() {
panic("goroutine panic") // crashes the whole program
}()
// When to use panic vs error:
// panic — unrecoverable programming errors (index out of bounds, nil deref)
// or internal invariant violations that should never happen
// error — expected failure conditions (file not found, network timeout, bad input)
// Libraries should NEVER let panics propagate to callers
// Use recover at the public API boundary to convert to errorsThe canonical use of panic/recover in Go is the library boundary pattern: a library may use panic internally for control flow (e.g., a parser that panics on syntax error deep in a call stack), but the exported function wraps the entire body in a deferred recover and converts the panic to an error value. This keeps the panic/recover internal and never surprises callers.
Go ships net/http/pprof (for running services) and the runtime/pprof package for programmatic profiling. Profiles are the primary tool for diagnosing CPU hotspots, memory leaks, and goroutine leaks in production.
// ── HTTP endpoint (register once, profile on demand) ──
import _ "net/http/pprof" // blank import registers handlers
import "net/http"
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// Profile endpoints:
// http://localhost:6060/debug/pprof/goroutine?debug=1 — goroutines
// http://localhost:6060/debug/pprof/heap — heap snapshot
// http://localhost:6060/debug/pprof/profile?seconds=30 — CPU profile
// ── CLI usage ──
// Download and view CPU profile:
// go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
// (pprof) top10 — top 10 functions by CPU
// (pprof) web — opens flame graph in browser
// (pprof) list myFunc — annotated source for myFunc
// Heap profile:
// go tool pprof http://localhost:6060/debug/pprof/heap
// (pprof) top10 -cum — cumulative allocation
// (pprof) alloc_space — total bytes allocated (not just live)
// (pprof) inuse_space — currently live bytes
// ── Benchmark profiling ──
// go test -bench=. -cpuprofile=cpu.out -memprofile=mem.out
// go tool pprof cpu.out
// ── Programmatic (for batch programs) ──
f, _ := os.Create("cpu.prof")
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
// ... run workload ...
// go tool pprof cpu.profThe execution tracer (go tool trace) complements pprof: it shows fine-grained goroutine scheduling, syscall latency, and GC events on a timeline — useful when pprof shows low CPU usage but latency is still high (often caused by goroutine blocking or GC pauses).
The race detector (-race flag) instruments the binary to track every memory access and detect data races — concurrent reads and writes to the same memory without synchronisation. It uses the ThreadSanitizer (TSan) C library under the hood.
// Run with race detection
// go run -race main.go
// go test -race ./...
// go build -race -o myapp
// Example of a data race
var counter int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // DATA RACE: concurrent unsynchronised write
}()
}
wg.Wait()
// Race detector output:
// ==================
// WARNING: DATA RACE
// Write at 0x00c000016090 by goroutine 7:
// main.main.func1()
// /tmp/main.go:14 +0x38
// Previous write at 0x00c000016090 by goroutine 6:
// main.main.func1()
// /tmp/main.go:14 +0x38
// ==================
// Fix: use atomic or mutex
var atomicCounter int64
atomic.AddInt64(&atomicCounter, 1) // race-free
// Race detector overhead:
// CPU: 5–20x slower
// Memory: 5–10x more
// Not suitable for production (unless you accept the overhead)
// Ideal in CI and -race enabled test suitesThe race detector uses happens-before analysis (Vector Clocks) to determine whether two conflicting accesses could overlap in time. It reports every detected race with full stack traces for both the current access and the previous conflicting access, making it extremely useful for tracking down subtle bugs. It is recommended to always run go test -race ./... in CI pipelines.
sync.Mutex is a mutual-exclusion lock: at most one goroutine holds the lock at any time, whether reading or writing. sync.RWMutex distinguishes readers from writers: multiple readers can hold the lock simultaneously (RLock), but a writer requires exclusive access (Lock).
// sync.Mutex — use when all accesses are writes or complex read+write ops
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
// sync.RWMutex — use when reads dominate
type Config struct {
mu sync.RWMutex
data map[string]string
}
func (c *Config) Get(key string) string {
c.mu.RLock() // many readers can hold RLock concurrently
defer c.mu.RUnlock()
return c.data[key]
}
func (c *Config) Set(key, val string) {
c.mu.Lock() // exclusive — no readers or writers
defer c.mu.Unlock()
c.data[key] = val
}
// Important: RWMutex is NOT always faster than Mutex
// RWMutex has higher per-operation overhead than Mutex
// It wins when: read:write ratio >> 10:1 and lock is held for a notable duration
// For very short critical sections, Mutex can be faster
// TryLock (Go 1.18+)
if c.mu.TryLock() {
defer c.mu.Unlock()
// got the lock
} else {
// lock is held — do something else
}Writer starvation: in Go's RWMutex, when a writer requests the lock, new readers are blocked even if current readers still hold RLock. This prevents writers from being starved by a continuous stream of readers. The writer waits only for the existing readers to finish, then gets exclusive access.
Go's concurrency mantra is "Do not communicate by sharing memory; instead, share memory by communicating." Channels are the primary tool for passing ownership of data between goroutines; mutexes are for protecting shared state that multiple goroutines need to access concurrently.
| Scenario | Preferred Tool |
|---|---|
| Passing ownership of data between goroutines | Channel |
| Signalling an event (done, cancel, ready) | Channel (or context.Context) |
| Pipeline of work items | Channel |
| Fan-out / fan-in patterns | Channel + sync.WaitGroup |
| Protecting a shared cache or counter | Mutex (sync.Mutex or atomic) |
| Read-heavy shared config or registry | sync.RWMutex or sync.Map |
| Updating a struct's fields | Mutex protecting the struct |
// ── Channel: ownership transfer ──
func producer(out chan<- int) {
for i := 0; i < 10; i++ {
out <- i // transfer ownership of i to consumer
}
close(out)
}
func consumer(in <-chan int) {
for v := range in {
fmt.Println(v)
}
}
// ── Mutex: protecting shared state ──
type Inventory struct {
mu sync.Mutex
items map[string]int
}
func (inv *Inventory) Add(name string, qty int) {
inv.mu.Lock()
defer inv.mu.Unlock()
inv.items[name] += qty
}
// ── Avoiding channel misuse ──
// Bad: using a channel as a simple mutex replacement
sem := make(chan struct{}, 1) // semaphore
sem <- struct{}{} // acquire
// critical section
<-sem // release
// Better: just use sync.Mutex for this pattern
// Context for cancellation (not raw channels)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
select {
case result := <-doWork(ctx):
fmt.Println(result)
case <-ctx.Done():
fmt.Println("timeout:", ctx.Err())
}
Go 1.18 introduced type parameters (generics), allowing functions and types to be parameterised over types constrained by interfaces. Go uses a GCShape-based implementation: rather than generating a separate binary for each concrete type (full monomorphisation), Go creates a dictionary-based dispatch for types with the same GC shape (memory layout), reducing binary size at a small runtime cost.
// Generic function with type constraint
func Min[T constraints.Ordered](a, b T) T {
if a < b { return a }
return b
}
fmt.Println(Min(3, 5)) // int
fmt.Println(Min(3.14, 2.71)) // float64
fmt.Println(Min("foo", "bar")) // string
// Generic type
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(item T) { s.items = append(s.items, item) }
func (s *Stack[T]) Pop() (T, bool) {
var zero T
if len(s.items) == 0 { return zero, false }
n := len(s.items) - 1
item := s.items[n]
s.items = s.items[:n]
return item, true
}
// Custom interface constraint
type Number interface {
~int | ~int32 | ~int64 | ~float32 | ~float64
}
func Sum[T Number](nums []T) T {
var total T
for _, n := range nums { total += n }
return total
}
// ~ means: any type whose underlying type is int
type MyInt int // ~int matches MyInt
fmt.Println(Sum([]MyInt{1, 2, 3})) // 6Performance: For pointer-sized types (interfaces, pointers, slices), Go generates a single implementation shared via a dictionary (like Java's type erasure). For value types (int, float64), Go can generate specialised code paths. In practice, generic functions are slightly slower than hand-written type-specific functions in some workloads but eliminate code duplication. Use generics when the algorithm is the same across types and the alternative is copy-paste or reflection.
context.Context carries deadlines, cancellation signals, and request-scoped values across API boundaries and goroutines. It is the standard way to propagate cancellation in Go services.
// Context hierarchy — child inherits cancellation from parent
ctx := context.Background() // root — never cancelled, never has deadline
// WithCancel — explicit cancellation
ctx1, cancel1 := context.WithCancel(ctx)
defer cancel1() // ALWAYS defer cancel to prevent goroutine leaks
// WithTimeout — automatically cancelled after duration
ctx2, cancel2 := context.WithTimeout(ctx, 5*time.Second)
defer cancel2()
// WithDeadline — cancelled at absolute time
deadline := time.Now().Add(10 * time.Second)
ctx3, cancel3 := context.WithDeadline(ctx, deadline)
defer cancel3()
// WithValue — carry request-scoped data (use sparingly)
type ctxKey string
ctx4 := context.WithValue(ctx, ctxKey("traceID"), "abc-123")
traceID := ctx4.Value(ctxKey("traceID")).(string)
// Propagate through function calls
func fetchData(ctx context.Context, url string) ([]byte, error) {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req) // cancels if ctx is done
if err != nil { return nil, err }
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
// Checking cancellation in a long loop
func processItems(ctx context.Context, items []Item) error {
for _, item := range items {
select {
case <-ctx.Done():
return ctx.Err() // context.DeadlineExceeded or context.Canceled
default:
process(item)
}
}
return nil
}Rules: (1) always pass Context as the first argument, never store it in a struct (for long-lived objects use context.Background() stored at service init). (2) always call the cancel function to avoid goroutine leaks — even if the deadline or timeout fires naturally. (3) use context.WithValue only for truly request-scoped data (trace IDs, auth tokens) — not as a general parameter-passing mechanism.
The unsafe package lets Go code step outside the type system and interact with raw memory. Its functions and types are special — the compiler handles them intrinsically. Using unsafe bypasses garbage collection safety and may break with future Go versions, so it should be used only in well-justified, carefully tested situations.
import "unsafe"
// unsafe.Sizeof — size of a type in bytes (compile-time)
type MyStruct struct {
a int32 // 4 bytes
b int64 // 8 bytes (8-byte aligned)
c int8 // 1 byte
// 7 bytes padding to next 8-byte boundary
}
fmt.Println(unsafe.Sizeof(MyStruct{})) // 24 (not 13!) — alignment padding
fmt.Println(unsafe.Alignof(MyStruct{}.b)) // 8
fmt.Println(unsafe.Offsetof(MyStruct{}.b)) // 8 (offset of field b)
// unsafe.Pointer — the 'escape hatch' for type-unsafe pointer conversion
// uintptr + unsafe.Pointer can do pointer arithmetic
// But: converting to uintptr makes the value opaque to GC — GC can move object!
// Zero-copy string <-> []byte (HIGH RISK — avoid in application code)
// Only safe when the lifetime and mutability constraints are guaranteed
func bytesToString(b []byte) string {
return *(*string)(unsafe.Pointer(&b))
}
// Struct field access via offset (used in reflect, cgo, sync internals)
// go:linkname — link to unexported symbols in other packages
// (used by stdlib, not for general use)
// Safe uses of unsafe:
// - Measuring struct size/alignment for documentation
// - Implementing generic data structures that need raw memory (arena allocators)
// - cgo interoperability
// - Performance-critical zero-copy conversions with proven safetyCritical GC hazard: when you convert unsafe.Pointer to uintptr, the GC no longer tracks it as a pointer — if the GC runs, it may move the object and the uintptr becomes a dangling reference. Always convert back to unsafe.Pointer in the same expression, never store a uintptr temporarily.
One of Go's most confusing bugs: a nil pointer of a concrete type, when assigned to an interface, produces a non-nil interface value. This breaks code that checks if err != nil — the check passes even though the underlying value is nil.
type MyError struct{ code int }
func (e *MyError) Error() string { return fmt.Sprintf("error %d", e.code) }
// Bug: returns a non-nil interface holding a nil *MyError
func riskyOp(fail bool) error {
var err *MyError // nil *MyError pointer
if fail {
err = &MyError{code: 42}
}
return err // WRONG: even when fail=false, the returned error is NOT nil
// The interface has {type=*MyError, data=nil}
}
e := riskyOp(false)
if e != nil {
fmt.Println("ERROR:", e) // This PRINTS — interface is non-nil!
}
// Fix 1: return untyped nil when there is no error
func safeOp(fail bool) error {
if fail {
return &MyError{code: 42}
}
return nil // nil interface — both type and data are nil
}
// Fix 2: use a concrete return type if the caller always knows the type
func safeOp2(fail bool) *MyError {
if fail { return &MyError{code: 42} }
return nil // now nil really means nil
}
// Detect with reflect if debugging:
fmt.Println(e == nil) // false (interface non-nil)
fmt.Println(reflect.ValueOf(e).IsNil()) // true (data pointer is nil)The rule: never return a typed nil as an interface return type. If your function returns an interface (like error), always return the bare nil keyword on the success path, not a nil pointer of a concrete type. The errors.Is and errors.As functions from Go 1.13+ are also affected — they work correctly because they unwrap the interface, but the initial != nil check still fails.
A goroutine leak occurs when a goroutine is started but never terminates — it blocks forever waiting on a channel, lock, or I/O operation that will never complete. Leaked goroutines consume memory (their stacks) and may hold references that prevent other objects from being GC'd. In a long-running server, goroutine leaks cause steady memory growth until OOM.
// Common leak pattern 1: unbuffered channel with no receiver
func leak1() {
ch := make(chan int)
go func() {
ch <- 42 // blocks forever — nobody reads
}()
// function returns; the goroutine is stuck sending forever
}
// Fix: use a buffered channel, or ensure the receiver runs
func fixed1() {
ch := make(chan int, 1) // buffered: sender doesn't block
go func() { ch <- 42 }()
// or: read from ch here before returning
}
// Common leak pattern 2: no cancellation signal
func leak2(ctx context.Context, jobs <-chan Job) {
go func() {
for {
job := <-jobs // blocks if jobs is never closed or ctx cancelled
process(job)
}
}()
}
// Fix: use select with ctx.Done()
func fixed2(ctx context.Context, jobs <-chan Job) {
go func() {
for {
select {
case <-ctx.Done(): return // clean exit on cancellation
case job := <-jobs: process(job)
}
}
}()
}
// Detecting leaks
// 1. runtime.NumGoroutine() — spot trend in tests or monitoring
// 2. http://localhost:6060/debug/pprof/goroutine?debug=2 — full traces
// 3. goleak library (uber-go/goleak) — assert no goroutines leak in tests
// import goleak "go.uber.org/goleak"
// func TestMyFunc(t *testing.T) {
// defer goleak.VerifyNone(t)
// myFunc()
// }
sync.WaitGroup lets one goroutine wait for a collection of goroutines to finish. The three methods — Add(n), Done(), and Wait() — implement a simple counting semaphore.
var wg sync.WaitGroup
// CORRECT: Add BEFORE launching the goroutine
for i := 0; i < 5; i++ {
wg.Add(1) // increment BEFORE go statement
go func(id int) {
defer wg.Done() // always use defer — ensures Done even on panic
fmt.Println("worker", id)
}(i)
}
wg.Wait() // blocks until count reaches zero
// WRONG: Add inside the goroutine — race condition
for i := 0; i < 5; i++ {
go func(id int) {
wg.Add(1) // may execute AFTER Wait() returns — data race!
defer wg.Done()
fmt.Println(id)
}(i)
}
wg.Wait() // might return before all goroutines call Add
// WRONG: reusing WaitGroup before Wait returns
// Do not call Add on a WaitGroup whose Wait has not yet returned
// Pattern: limit concurrency with a semaphore channel
sem := make(chan struct{}, 10) // max 10 goroutines at once
for _, item := range items {
wg.Add(1)
sem <- struct{}{} // acquire slot
go func(it Item) {
defer func() { <-sem; wg.Done() }()
process(it)
}(item)
}
wg.Wait()A WaitGroup must not be copied after first use — embedding a WaitGroup in a struct and passing the struct by value silently copies the counter state. Always pass pointers to structs containing a WaitGroup, or pass the WaitGroup itself by pointer.
A closure in Go captures variables by reference — it holds a pointer to the outer variable, not a copy of its value at the time of closure creation. This means if the variable changes after the closure is created but before it executes, the closure sees the new value.
// ── Classic goroutine loop bug ──
// Go 1.21 and earlier:
for i := 0; i < 3; i++ {
go func() {
fmt.Println(i) // captures &i — all goroutines print 3, 3, 3
}()
}
time.Sleep(time.Second)
// Fix 1: pass as argument (creates a copy per iteration)
for i := 0; i < 3; i++ {
go func(i int) { // i is now a local copy
fmt.Println(i) // 0, 1, 2 (in some order)
}(i)
}
// Fix 2: shadow the variable inside the loop (pre-Go 1.22)
for i := 0; i < 3; i++ {
i := i // new i per iteration
go func() { fmt.Println(i) }()
}
// Go 1.22+ fix: loop variables are per-iteration by default
// The behaviour changed — each loop iteration now has its own i
// So the bug no longer exists in Go 1.22+ for range loops
// Closures in non-goroutine contexts
adders := make([]func() int, 3)
for i := 0; i < 3; i++ {
i := i // shadow is needed pre-Go-1.22
adders[i] = func() int { return i + 10 }
}
fmt.Println(adders[0](), adders[1](), adders[2]()) // 10 11 12Go 1.22 loop variable change: starting in Go 1.22, the loop variable in a for range loop is declared fresh each iteration (similar to JavaScript's let). This silently fixes the classic goroutine loop bug for range loops in new code. Classic three-clause for i := 0; ... loops also got the fix in 1.22. Code that depended on the old behaviour (sharing across iterations) may break.
Go has no class hierarchy or classical inheritance. Instead it supports composition via embedding: embedding a type inside a struct promotes the embedded type's methods and fields to the outer struct. This provides code reuse and satisfies interfaces without the coupling of inheritance.
type Logger struct{ prefix string }
func (l Logger) Log(msg string) { fmt.Println(l.prefix+":", msg) }
type Server struct {
Logger // embedded — methods promoted
addr string
}
s := Server{Logger: Logger{"SERVER"}, addr: ":8080"}
s.Log("starting") // promoted — same as s.Logger.Log("starting")
s.prefix = "SRV" // promoted field access
// Embedding satisfies interfaces
type Loggable interface { Log(string) }
var l Loggable = s // Server satisfies Loggable via embedded Logger
// Method overriding — outer type can shadow embedded method
func (s Server) Log(msg string) {
fmt.Printf("[%s] %s\n", s.addr, msg) // custom implementation
// s.Logger.Log(msg) // explicitly call embedded if needed
}
// Interface embedding — compose interface contracts
type ReadWriter interface {
io.Reader // embedded interface
io.Writer
}
// Embedding vs named field
type WithName struct {
myLogger Logger // named field — NOT promoted, access as s.myLogger.Log()
}
type WithEmbed struct {
Logger // embedded — methods promoted to outer type
}The key difference from inheritance: embedding is purely mechanical code promotion. The embedded type does not know about the outer type and there is no polymorphism between them unless they share an interface. You can embed multiple types (mixins), and method sets are resolved deterministically — if the outer type defines a method with the same name, it shadows the embedded one.
Go 1.13 introduced a standardised error wrapping API. Errors can be wrapped using fmt.Errorf("... %w", err) to create a chain, and errors.Is / errors.As traverse that chain to find specific errors or extract their values.
import "errors"
// Sentinel errors — comparable with == or errors.Is
var ErrNotFound = errors.New("not found")
var ErrPermission = errors.New("permission denied")
// Wrap — %w creates an error chain
func openFile(path string) error {
if err := os.Open(path); err != nil {
return fmt.Errorf("openFile %s: %w", path, err) // wrap with context
}
return nil
}
// errors.Is — checks if any error in the chain equals the target
err := openFile("/nonexistent")
if errors.Is(err, os.ErrNotExist) {
fmt.Println("file does not exist")
}
// Custom error type
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message)
}
func validate(name string) error {
if name == "" {
return fmt.Errorf("user creation: %w", &ValidationError{"name", "required"})
}
return nil
}
// errors.As — extracts a specific type from the chain
err = validate("")
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("Field: %s, Message: %s\n", ve.Field, ve.Message)
}
// errors.Unwrap — one level only
inner := errors.Unwrap(err) // returns the wrapped *ValidationError
// Multiple wrapping (Go 1.20+) — join multiple errors
err1, err2 := errors.New("e1"), errors.New("e2")
joined := errors.Join(err1, err2)
errors.Is(joined, err1) // true
Go's testing package includes a built-in benchmark framework. Benchmarks are functions with the signature func BenchmarkXxx(b *testing.B) and run with go test -bench=.. The framework handles warm-up and calibrates the number of iterations automatically.
// benchmark_test.go
func BenchmarkStringConcat(b *testing.B) {
// b.N is set by the framework — run the measured code exactly b.N times
for i := 0; i < b.N; i++ {
s := ""
for j := 0; j < 100; j++ {
s += "x" // inefficient — baseline
}
_ = s
}
}
func BenchmarkStringBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
for j := 0; j < 100; j++ {
sb.WriteString("x")
}
_ = sb.String()
}
}
// Run benchmarks:
// go test -bench=. -benchmem ./...
// BenchmarkStringConcat-8 123456 9876 ns/op 5264 B/op 99 allocs/op
// BenchmarkStringBuilder-8 456789 2345 ns/op 128 B/op 2 allocs/op
// -benchmem: shows memory allocations per op
// -benchtime=5s: run each benchmark for 5 seconds
// -count=5: run each benchmark 5 times for statistical stability
// Reset timer to exclude setup time
func BenchmarkWithSetup(b *testing.B) {
data := make([]byte, 1<<20) // setup — excluded from timing
b.ResetTimer() // start timing from here
for i := 0; i < b.N; i++ {
process(data)
}
}
// Run with pprof to get a flame graph:
// go test -bench=BenchmarkStringConcat -cpuprofile=cpu.out
// go tool pprof cpu.outThe benchstat tool (part of golang.org/x/perf) compares two sets of benchmark results statistically, accounting for variance — essential for determining whether an optimisation is genuinely significant or within noise.
CPU architectures require data to be aligned — an 8-byte integer must start at an address divisible by 8, a 4-byte integer divisible by 4, etc. The Go compiler adds invisible padding bytes between struct fields to satisfy alignment requirements. Poor field ordering wastes memory; reordering fields can eliminate padding.
// Poorly ordered — wastes 7 bytes of padding
type Wasteful struct {
a bool // 1 byte
// 7 bytes PADDING (for b's 8-byte alignment)
b int64 // 8 bytes
c bool // 1 byte
// 7 bytes PADDING (to align next field / end of struct)
} // Total: 24 bytes (wastes 14 bytes)
// Well ordered — no padding (fields largest to smallest)
type Compact struct {
b int64 // 8 bytes
a bool // 1 byte
c bool // 1 byte
// 6 bytes padding to align to 8-byte boundary at struct end
} // Total: 16 bytes (saves 8 bytes vs Wasteful)
// Verify with unsafe
fmt.Println(unsafe.Sizeof(Wasteful{})) // 24
fmt.Println(unsafe.Sizeof(Compact{})) // 16
// Performance impact:
// Larger structs → more cache lines → more cache misses in hot loops
// A 50% size reduction can be a 2x performance improvement in array iteration
// Tool: fieldalignment (golang.org/x/tools/go/analysis/passes/fieldalignment)
// go install golang.org/x/tools/cmd/fieldalignment@latest
// fieldalignment ./... — reports suboptimal struct layouts
// fieldalignment -fix ./.. — rewrites structs (review before committing!)
// go vet includes a similar check with: -structtag flagThe rule of thumb: order struct fields from largest to smallest alignment requirement. In practice: int64/float64/uintptr (8 bytes) first, then int32/float32 (4 bytes), then int16 (2 bytes), then bool/int8/byte (1 byte) last. This is particularly important for structs that appear in large arrays or slices processed in hot paths.
Fan-out: one goroutine distributes work to multiple worker goroutines. Fan-in: multiple goroutines send results back to a single aggregator. Together they form Go's most common concurrency idiom for parallel pipelines.
// Fan-out / Fan-in pipeline
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums { out <- n }
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in { out <- n * n }
}()
return out
}
// Fan-out: start N workers on the same input channel
func fanOut(in <-chan int, workers int) []<-chan int {
outs := make([]<-chan int, workers)
for i := 0; i < workers; i++ {
outs[i] = square(in) // each reads from the shared input
}
return outs
}
// Fan-in: merge N result channels into one
func fanIn(ctx context.Context, channels ...<-chan int) <-chan int {
var wg sync.WaitGroup
merged := make(chan int)
forward := func(c <-chan int) {
defer wg.Done()
for n := range c {
select {
case merged <- n:
case <-ctx.Done(): return
}
}
}
wg.Add(len(channels))
for _, c := range channels { go forward(c) }
go func() { wg.Wait(); close(merged) }()
return merged
}
// Wire it up
nums := generate(1, 2, 3, 4, 5)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
results := fanIn(ctx, fanOut(nums, 3)...)
for r := range results { fmt.Println(r) }
The reflect package provides runtime type introspection. It lets you inspect types and values at runtime, set values dynamically, and call methods whose signatures are not known at compile time. It is the foundation of JSON marshalling, ORM field mapping, and dependency injection frameworks.
import "reflect"
// reflect.TypeOf and reflect.ValueOf
type Person struct {
Name string `json:"name"`
Age int `json:"age"`
}
p := Person{"Alice", 30}
t := reflect.TypeOf(p)
v := reflect.ValueOf(p)
fmt.Println(t.Name()) // "Person"
fmt.Println(t.Kind()) // struct
fmt.Println(v.Field(0)) // Alice
fmt.Println(v.Field(0).Type()) // string
// Iterating struct fields
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
val := v.Field(i)
tag := field.Tag.Get("json")
fmt.Printf("%s (%s) [json:%s] = %v\n", field.Name, field.Type, tag, val)
}
// Setting values via reflect — requires addressable value
vp := reflect.ValueOf(&p).Elem() // dereference pointer to get addressable value
vp.FieldByName("Name").SetString("Bob")
fmt.Println(p.Name) // Bob
// Calling methods dynamically
method := v.MethodByName("String") // if Person has a String() method
if method.IsValid() {
results := method.Call(nil)
fmt.Println(results[0])
}
// reflect.DeepEqual — structural equality (used in tests)
fmt.Println(reflect.DeepEqual([]int{1,2,3}, []int{1,2,3})) // truePerformance warning: reflection is 10–100× slower than direct type-specific code because it bypasses compiler optimisations. Use reflection in framework/library code that genuinely needs runtime type introspection; avoid it in hot paths. Go 1.18+ generics often replace the need for reflection in type-agnostic algorithms.
cgo allows Go programs to call C functions and vice versa. It is used for binding to C libraries (OpenSSL, SQLite, CUDA), OS system calls not exposed in the Go standard library, and legacy C codebases.
// Simple cgo example
package main
// #include
// #include
// char* greet(const char* name) {
// char* result = malloc(100);
// snprintf(result, 100, "Hello, %s!", name);
// return result;
// }
import "C"
import (
"fmt"
"unsafe"
)
func main() {
cname := C.CString("World") // Go string → C string (malloc)
defer C.free(unsafe.Pointer(cname)) // must free C memory!
cresult := C.greet(cname) // call C function
result := C.GoString(cresult) // C string → Go string (copy)
C.free(unsafe.Pointer(cresult))
fmt.Println(result) // Hello, World!
}
// cgo overhead per call:
// 60-200 ns per C call (vs ~1 ns for a plain Go function call)
// The runtime must save goroutine state, switch stack frames, etc.
// Cross-compilation challenge:
// CGO_ENABLED=0 disables cgo and allows full static binary cross-compilation
// CGO_ENABLED=1 (default) requires a C compiler on the target platform
// Alternatives to cgo:
// - Pure Go implementations (avoid cgo entirely)
// - WASI / WebAssembly for sandboxed native code
// - gRPC subprocess calling a C binary Key costs of cgo: (1) each C call is ~60–200 ns overhead — avoid calling C in tight loops. (2) goroutines calling C must have the C function run on an OS thread — the Go scheduler complexity increases. (3) C memory is not managed by the Go GC — you must explicitly free it. (4) cross-compilation is much harder with cgo enabled.
GOMAXPROCS sets the number of OS threads (Ps — Processors in the GMP model) that can execute Go code simultaneously. It defaults to the number of logical CPUs available (runtime.NumCPU()). Increasing it allows more goroutines to run in true parallel; the constraint is the number of physical cores, not the number of goroutines.
import "runtime"
// Query current GOMAXPROCS
fmt.Println(runtime.GOMAXPROCS(0)) // 0 = query without changing
fmt.Println(runtime.NumCPU()) // logical CPUs (may include HyperThreading)
// Set programmatically (returns old value)
old := runtime.GOMAXPROCS(4)
// Environment variable
// GOMAXPROCS=2 go run main.go
// Effect on CPU-bound vs I/O-bound workloads:
// CPU-bound: increasing GOMAXPROCS → true parallelism, up to NumCPU
// I/O-bound: even GOMAXPROCS=1 can handle millions of concurrent goroutines
// (blocked goroutines don't hold a P)
// Container / cloud consideration:
// GOMAXPROCS defaults to host CPU count, NOT container CPU quota
// This causes over-scheduling in containers with CPU limits
// Fix: use uber-go/automaxprocs
// import _ "go.uber.org/automaxprocs" // reads cgroup quota automatically
// Benchmark: optimal GOMAXPROCS depends on workload
// CPU-bound matrix multiply: scales linearly to NumCPU
// I/O-bound HTTP server: GOMAXPROCS=2-4 often sufficient; more P's = more
// scheduler overhead without extra throughput on I/O-bound work
// GOMAXPROCS=1 guarantees sequential goroutine execution (useful in tests)
// Some data-race bugs only appear with GOMAXPROCS > 1Container pitfall: by default, Go reads GOMAXPROCS from the host's CPU count, not the container's CPU quota. A Go service running in a 0.5-CPU container may spawn 32 OS threads (matching a 32-core host), causing severe context-switching overhead and latency. The uber-go/automaxprocs library fixes this by reading the cgroup CPU quota and setting GOMAXPROCS accordingly.
sync.Once guarantees that a function is executed exactly once, regardless of how many goroutines call it concurrently. It is the idiomatic Go way to implement lazy initialisation and singletons without explicit locking in user code.
var (
instance *Database
once sync.Once
)
func GetDB() *Database {
once.Do(func() {
// This function runs exactly once across all goroutines
instance = &Database{
conn: openConnection(config),
}
})
return instance // safe to return — once.Do blocks until init completes
}
// All concurrent callers either:
// - Execute the function themselves (first caller)
// - Block until the function completes and then return (all others)
// ERROR HANDLING in sync.Once — no built-in mechanism
// Pattern: store the error alongside the result
type singleton struct {
db *Database
err error
}
var s singleton
var initOnce sync.Once
func getDB() (*Database, error) {
initOnce.Do(func() {
s.db, s.err = openDB()
})
return s.db, s.err
}
// sync.OnceFunc (Go 1.21) — wraps a function so it runs at most once
initDB := sync.OnceFunc(func() { /* initialise */ })
initDB() // safe to call from multiple goroutines
initDB() // no-op — function already ran
// sync.OnceValue / sync.OnceValues (Go 1.21) — return a value
getConfig := sync.OnceValue(func() *Config { return loadConfig() })
cfg := getConfig() // computed once, cachedsync.Once has no reset mechanism — once the function has run, there is no way to make it run again. This is by design. If you need resettable one-time execution, use a mutex-protected boolean flag instead. Go 1.21 added sync.OnceFunc, sync.OnceValue, and sync.OnceValues as ergonomic wrappers.
Go's GC handles most memory management, but certain patterns prevent objects from being collected even when they are logically no longer needed:
| Pattern | Cause | Fix |
|---|---|---|
| Goroutine leak | Goroutine blocked forever on channel/I/O | Use context cancellation or close channels |
| Slice sub-slice holding large backing array | Small sub-slice keeps 100 MB backing array alive | Copy sub-slice: copy(small, big[start:end]) |
| Global variable accumulation | Global map or slice grown indefinitely | Add eviction, use expiring cache, or bounded data structure |
| Finalizer keeping object alive | Objects with finalizers are delayed one GC cycle | Avoid finalizers; use Cleaner or defer with explicit close |
| String conversion retaining bytes | []byte → string keeps original byte array | Use string(bytes) to copy; beware zero-copy hacks |
| time.Ticker not stopped | Ticker goroutine and channel alive until GC | Always call ticker.Stop() and drain the channel |
// Slice leak — sub-slice keeps large backing array alive
func getHeader(data []byte) []byte {
return data[:8] // BAD: keeps all of data alive
}
func getHeaderFixed(data []byte) []byte {
header := make([]byte, 8)
copy(header, data) // GOOD: independent small slice
return header
}
// Ticker leak — always stop
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop() // MUST stop to avoid goroutine+channel leak
for {
select {
case <-ticker.C: doWork()
case <-ctx.Done(): return
}
}
// Diagnosing memory leaks
// 1. Monitor heap via /debug/pprof/heap
// 2. Compare two heap snapshots over time:
// go tool pprof -base old.pprof new.pprof
// 3. Watch runtime.ReadMemStats — if HeapAlloc keeps growing
// after GC, something is holding references
// 4. goroutine profile: /debug/pprof/goroutine?debug=1
// if NumGoroutine keeps growing, you have goroutine leaks
Build constraints allow you to conditionally include or exclude Go source files based on the target OS, architecture, Go version, or custom tags. They are essential for platform-specific code, test-only code, and feature flags at build time.
// Modern syntax (Go 1.17+) — //go:build directive
// Place this as the FIRST non-blank, non-comment line
//go:build linux && amd64
package main
// This file is only compiled on Linux/amd64
func platformSpecific() { /* ... */ }
// Combining constraints
//go:build (darwin || linux) && !386
// Custom tags — enable with: go build -tags integration
//go:build integration
package main
func TestIntegration(t *testing.T) { /* runs only with -tags integration */ }
// OS and architecture tags (auto-detected from filename or directive)
// file_linux.go — only on Linux
// file_windows_amd64.go — Windows + amd64
// file_test.go — only in test builds
// Go version constraint
//go:build go1.21
// Negate a tag
//go:build !cgo
// Run tests with a custom tag:
// go test -tags integration ./...
// Build ignoring all tags:
// go build -tags '' ./...
// Legacy syntax (still supported for compatibility):
// // +build linux
// (Note: one blank line between +build and package declaration)The filename convention (_linux.go, _amd64.go) is a shorthand that the Go toolchain parses automatically — equivalent to a //go:build directive. File suffix constraints use underscore-separated GOOS and GOARCH values. If a file has both a filename constraint and a //go:build directive, both must be satisfied.
io.Reader and io.Writer are the two most important interfaces in the Go standard library. Their simplicity (one method each) enables an enormous amount of composition and abstraction across files, network connections, bytes buffers, gzip streams, crypto, and more.
// The core interfaces:
// type Reader interface { Read(p []byte) (n int, err error) }
// type Writer interface { Write(p []byte) (n int, err error) }
// io.Copy — generic transfer between any Reader and Writer
func Copy(dst io.Writer, src io.Reader) (written int64, err error)
// Examples of io.Reader implementations:
// *os.File, *bytes.Buffer, *strings.Reader, net.Conn, *gzip.Reader
// http.Response.Body, *bufio.Reader, *io.LimitedReader
// Composing readers
func processRequest(r *http.Request) {
// Layer buffering, decompression, and limiting in one chain
limited := io.LimitReader(r.Body, 10<<20) // max 10 MB
gzr, _ := gzip.NewReader(limited) // decompress
buffered := bufio.NewReader(gzr) // buffer reads
// Now read from buffered — all layers transparent
line, _, _ := buffered.ReadLine()
fmt.Println(string(line))
}
// Custom Reader — generate Fibonacci numbers as bytes
type FibReader struct{ a, b int }
func (f *FibReader) Read(p []byte) (int, error) {
if len(p) == 0 { return 0, nil }
n := copy(p, []byte(fmt.Sprintf("%d ", f.a)))
f.a, f.b = f.b, f.a+f.b
return n, nil // io.EOF to signal end of stream
}
// io.TeeReader — read from r while writing to w simultaneously
var buf bytes.Buffer
tee := io.TeeReader(r.Body, &buf) // buf captures what is read from r.Body
io.Copy(downstream, tee)The Read contract: a successful read returns n > 0, err == nil or final data with err == io.EOF. A Read may return n > 0 together with io.EOF in the same call. Callers must process the n bytes before inspecting the error. Never assume Read fills the entire buffer — always loop until io.EOF or use io.ReadFull.
Premature optimisation is wasteful; uninformed optimisation is harmful. The Go ecosystem provides a disciplined, measurement-driven workflow: profile first, identify the actual bottleneck, optimise, verify the improvement, and repeat.
// Step 1: establish baseline with benchmarks
// go test -bench=BenchmarkFoo -count=5 -benchmem > before.txt
// Step 2: collect a CPU profile
// go test -bench=BenchmarkFoo -cpuprofile=cpu.out
// go tool pprof cpu.out
// (pprof) top10 — hottest functions
// (pprof) web — flame graph (requires graphviz)
// (pprof) list hotFunc — annotated source
// Step 3: collect a memory profile
// go test -bench=BenchmarkFoo -memprofile=mem.out
// go tool pprof mem.out
// (pprof) top10 -cum — allocation hotspots
// Step 4: check escape analysis — are expected stack allocs escaping?
// go build -gcflags="-m -m" ./...
// Step 5: common optimisations to check:
// - Replace []byte string(b) conversions in hot paths
// - Pre-allocate slices with known capacity: make([]T, 0, n)
// - Use sync.Pool for frequently allocated/freed objects
// - Replace interface{} parameters with generics (Go 1.18+)
// - Replace reflect with code generation (go generate)
// - Use buffered I/O (bufio.Writer) instead of unbuffered
// Step 6: verify improvement
// go test -bench=BenchmarkFoo -count=5 -benchmem > after.txt
// benchstat before.txt after.txt
// Outputs: delta, p-value, whether improvement is statistically significant
// Step 7: run with -race to ensure no correctness regression
// go test -race ./...The golden rule: measure first. Intuition about where time is spent is usually wrong. The pprof CPU profile shows the actual hot path. A 10% improvement in a function that takes 1% of total time is invisible; a 10% improvement in a function that takes 80% is significant. Use benchstat to confirm improvements are statistically significant and not just noise.
sync.Pool is a thread-safe pool of temporarily reusable objects. It reduces GC pressure by allowing objects to be returned to the pool after use and reused by the next caller, avoiding repeated heap allocation and deallocation.
// Typical use: expensive-to-allocate, frequently used objects
var bufPool = sync.Pool{
New: func() any {
// Called when pool is empty — allocate a fresh object
buf := make([]byte, 0, 4096)
return &buf
},
}
func processRequest(data []byte) string {
// Get a buffer from the pool (may be a fresh allocation or reused)
bufPtr := bufPool.Get().(*[]byte)
buf := (*bufPtr)[:0] // reset length, keep capacity
// ... use buf for scratch work ...
buf = append(buf, data...)
result := string(buf)
// Return to pool — DON'T use buf after this
*bufPtr = buf
bufPool.Put(bufPtr)
return result
}
// Important constraints:
// 1. The GC MAY clear the pool between any two GC cycles
// Do NOT rely on objects in the pool surviving across GCs
// 2. Objects must be safe to reset to a clean state before reuse
// 3. Do NOT store pointers to pool objects — return them before the caller returns
// Real-world examples:
// - encoding/json uses sync.Pool for encoder/decoder scratch buffers
// - fmt uses sync.Pool for pp (print state) objects
// - net/http uses sync.Pool for request buffers
// Anti-patterns:
// - Pooling objects that hold open file descriptors or connections
// - Pooling objects that are expensive to validate/clean on reuse
// - Using Pool for long-lived objects (GC will evict them)The GC clears sync.Pool during each collection cycle — pool items are not permanent. This is intentional: the pool exists only to reduce per-GC-cycle allocation pressure, not to replace a cache. If you need objects to survive GC cycles, use a channel-based pool or a properly-designed LRU cache.
Type assertions and type switches are used to recover the concrete type from an interface value. Their performance characteristics matter in hot paths because they involve pointer comparisons and potentially method table lookups.
// Single type assertion
var v interface{} = "hello"
// Panicking form — use only when you are certain of the type
s := v.(string) // panics if v is not a string
// Safe form — never panics
s, ok := v.(string) // ok=false if wrong type
if !ok {
// handle mismatch
}
// Type switch — more efficient than chained if assertions
// The compiler generates optimised comparison code
func describe(v interface{}) string {
switch x := v.(type) {
case int: return fmt.Sprintf("int: %d", x)
case string: return fmt.Sprintf("string: %q", x)
case bool: return fmt.Sprintf("bool: %v", x)
case []byte: return fmt.Sprintf("bytes: %d bytes", len(x))
default: return fmt.Sprintf("unknown: %T", x)
}
}
// Performance:
// Single type assertion: ~1-3 ns — pointer comparison on the type word
// Type switch with many cases: O(n) comparisons unless compiler optimises
// For hot paths with many types: use a map[reflect.Type]func() or generics
// Interface-to-interface assertion: also cheap (same mechanism)
type Stringer interface{ String() string }
var r io.Reader = os.Stdin
if s, ok := r.(Stringer); ok {
fmt.Println(s.String()) // os.File implements Stringer
}A type assertion compares the concrete type pointer stored in the interface's type word against the target type. For interface-to-interface assertions, the runtime checks whether the concrete type implements the target interface by looking up the appropriate itab. The cache of itab lookups makes repeated interface assertions amortised O(1).
The Go module system (introduced in Go 1.11, stable in Go 1.13) is the standard dependency management mechanism. A module is a collection of Go packages with a go.mod file at its root that declares the module path, Go version, and dependencies.
// go.mod — describes this module's dependencies
module github.com/myorg/myapp
go 1.21
require (
github.com/gin-gonic/gin v1.9.1
golang.org/x/sync v0.6.0
)
// go.sum — cryptographic checksums for dependencies
// github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
// github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
// Key commands:
// go mod init github.com/myorg/myapp — create go.mod
// go get github.com/pkg/errors@v0.9.1 — add/update a dependency
// go mod tidy — remove unused, add missing deps
// go mod download — pre-download modules to cache
// go mod vendor — copy deps into ./vendor
// go list -m all — list all deps (direct + indirect)
// Minimum Version Selection (MVS):
// Go selects the minimum version of each dependency that satisfies all requirements
// This is deterministic and avoids 'dependency hell'
// If A requires B>=1.2 and C requires B>=1.5, Go selects B@1.5 (minimum satisfying all)
// Replace directive — useful for local development or forking
replace github.com/vendor/lib => ../local-lib
// Exclude — skip a specific buggy version
exclude github.com/vendor/lib v1.2.3go.sum contains SHA-256 checksums (h1: hashes) for every dependency's source tree and its go.mod file. The Go toolchain verifies these checksums against the checksum database (sum.golang.org) on every download. This provides supply-chain security: a tampered dependency produces a checksum mismatch and the build fails. Commit both go.mod and go.sum to version control.
A worker pool limits the number of goroutines working concurrently, preventing resource exhaustion when processing a large number of tasks. It is one of the most common Go concurrency patterns.
// Classic worker pool pattern
func workerPool(ctx context.Context, jobs <-chan Job, results chan<- Result, numWorkers int) {
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return // cancelled
case job, ok := <-jobs:
if !ok { return } // channel closed — no more jobs
result := process(job)
select {
case results <- result:
case <-ctx.Done(): return
}
}
}
}(i)
}
// Close results after all workers finish
go func() {
wg.Wait()
close(results)
}()
}
// Usage
const numWorkers = 10
jobs := make(chan Job, 100)
results := make(chan Result, 100)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
workerPool(ctx, jobs, results, numWorkers)
// Feed jobs
go func() {
defer close(jobs)
for _, job := range allJobs {
select {
case jobs <- job:
case <-ctx.Done(): return
}
}
}()
// Collect results
for r := range results {
fmt.Println(r)
}The worker pool pattern ensures bounded concurrency — with virtual threads in other languages this is less critical, but in Go it matters when each goroutine holds OS resources (database connections, file handles) that are finite. Set numWorkers based on the resource constraint: for I/O-bound work constrained by a connection pool of size N, use N workers. For CPU-bound work, use runtime.NumCPU() workers.
Go's tooling ecosystem provides multiple layers of static analysis, from the built-in go vet to powerful third-party linters. Using them as part of CI prevents entire categories of bugs.
| Tool | Command | What it catches |
|---|---|---|
| go vet | go vet ./... | Misuse of Printf verbs, unreachable code, copying sync.Mutex, shadowed variables, incorrect struct tags |
| go build | go build ./... | Compile errors, unused imports, missing packages |
| go test -race | go test -race ./... | Data races — concurrent unsynchronised memory access |
| staticcheck | staticcheck ./... | Deprecated API use, performance issues, dead code, semantic bugs go vet misses |
| golangci-lint | golangci-lint run | Meta-linter running 50+ linters including vet, staticcheck, errcheck, gocritic |
| errcheck | errcheck ./... | Unchecked error return values (a very common Go bug source) |
| govulncheck | govulncheck ./... | Known vulnerabilities in dependencies (Go's official security scanner) |
// .golangci.yml — configuration for golangci-lint in CI
# linters:
# enable:
# - govet
# - staticcheck
# - errcheck
# - gosec
# - misspell
# - gofmt
# - revive
// Example: errcheck catches a common bug
os.Remove(tmpFile) // BAD: ignoring error return
if err := os.Remove(tmpFile); err != nil { // GOOD
log.Println("cleanup failed:", err)
}
// go vet catches Printf format mismatches
fmt.Printf("%d", "hello") // go vet: argument "hello" is a string, not int
// govulncheck — check for known CVEs
// go install golang.org/x/vuln/cmd/govulncheck@latest
// govulncheck ./...
// Vulnerability #1: GO-2023-1234 in github.com/vulnerable/pkg@v1.2.3
// Recommended CI pipeline:
// 1. go build ./...
// 2. go vet ./...
// 3. go test -race -count=1 ./...
// 4. golangci-lint run
// 5. govulncheck ./...
