Go - Memory & Data Semantics
Concept: Go is a statically-typed language where types are paramount.
Why it matters: The type system catches entire classes of bugs at compile time; understanding it is the foundation of correct Go.
package main
import "fmt"
// compiler rejects the assignment — types are checked before the program runs
func main() {
var count int = 42
var label string = "items"
// count = label // compile error: cannot use string as int
// label = count // compile error: cannot use int as string
fmt.Println(count, label) // only reachable if types are correct
}
Gotcha: Reaching for interface{} to "avoid type errors" defers them to runtime panics instead.
Concept: Variables provide a symbolic name to a memory location.
Why it matters: Understanding that a variable IS a memory address (not a value box) explains why pointer semantics work the way they do.
package main
import "fmt"
func main() {
x := 42
// &x is the actual memory address x refers to
fmt.Printf("value=%d address=%p\n", x, &x)
// two variables at different addresses, same value
y := 42
fmt.Printf("x addr=%p y addr=%p same? %v\n", &x, &y, &x == &y)
}
Gotcha: Assuming two variables with the same value share the same address — they don't unless aliased via pointer.
Concept: Type defines the amount of memory a variable uses and how that memory is interpreted.
Why it matters: The same 8 bytes mean completely different things as int64, float64, or [8]byte — the type is the interpreter.
package main
import (
"fmt"
"math"
"unsafe"
)
func main() {
var i int64 = 1
var f float64 = 1.0
// same bit pattern means different things
fmt.Printf("int64 size=%d bytes value=%d\n", unsafe.Sizeof(i), i)
fmt.Printf("float64 size=%d bytes value=%f\n", unsafe.Sizeof(f), f)
// float64 bit representation of 1.0 is NOT the integer 1
fmt.Printf("float64(1.0) bits = %064b\n", math.Float64bits(1.0))
}
Gotcha: Using unsafe.Pointer to reinterpret memory bypasses the type system — the GC may move the data underneath you.
Concept: Byte — the fundamental unit of memory in Go, consisting of 8 bits.
Why it matters: Every type is ultimately measured in bytes; understanding byte size predicts memory layout and performance.
package main
import (
"fmt"
"unsafe"
)
func main() {
// every type measured in bytes — unsafe.Sizeof reveals the cost
fmt.Printf("bool = %d byte\n", unsafe.Sizeof(true))
fmt.Printf("int8 = %d byte\n", unsafe.Sizeof(int8(0)))
fmt.Printf("int32 = %d bytes\n", unsafe.Sizeof(int32(0)))
fmt.Printf("int64 = %d bytes\n", unsafe.Sizeof(int64(0)))
fmt.Printf("float64 = %d bytes\n", unsafe.Sizeof(float64(0)))
fmt.Printf("string = %d bytes (header)\n", unsafe.Sizeof(""))
}
Gotcha: Using int when you need a fixed-size type — int is 4 bytes on 32-bit and 8 bytes on 64-bit platforms.
Concept: Zero Value — the default value a variable gets upon declaration if no explicit initialization is provided.
Why it matters: Go guarantees zero values — no uninitialized memory bugs; you can always reason about the state of a declared variable.
package main
import "fmt"
type Config struct {
Host string // ""
Port int // 0
TLS bool // false
Timeout float64 // 0.0
}
func main() {
var cfg Config // zero value — usable immediately, no constructor needed
// zero value is a valid default — set only what differs
cfg.Host = "localhost"
cfg.Port = 8080
fmt.Printf("%+v\n", cfg)
}
Gotcha: Checking if cfg.Port == 0 to detect "not set" breaks when 0 is a valid value — use a pointer or explicit boolean flag.
Concept: Word — a generic memory allocation unit (4 bytes on 32-bit, 8 bytes on 64-bit).
Why it matters: Pointers, integers, and slice headers are all word-sized; knowing this predicts struct size and alignment.
package main
import (
"fmt"
"unsafe"
)
func main() {
// pointer size == word size — reveals the platform word width
var p *int
wordSize := unsafe.Sizeof(p)
fmt.Printf("word size = %d bytes\n", wordSize) // 8 on 64-bit
// interface is 2 words: type pointer + data pointer
var i interface{}
fmt.Printf("interface size = %d bytes (2 words)\n", unsafe.Sizeof(i))
// slice is 3 words: pointer + len + cap
var s []int
fmt.Printf("slice header = %d bytes (3 words)\n", unsafe.Sizeof(s))
}
Gotcha: Assuming pointer size is always 8 bytes — code that embeds pointer size as a literal breaks on 32-bit platforms.
Concept: var keyword is preferred for zero-value variable declaration.
Why it matters: var signals intent — "I want the zero value" — making the code's purpose clear to the reader.
package main
import "fmt"
func main() {
// var signals "zero value is intentional"
var count int // 0 — waiting to be incremented
var name string // "" — waiting to be assigned
var ready bool // false — not ready yet
var scores []int // nil slice — no backing array yet
fmt.Println(count, name, ready, scores)
// contrast: := signals "I have a non-zero value right now"
total := 100
label := "items"
fmt.Println(total, label)
}
Gotcha: Using := 0 or := "" for zero-value initialization — var is more idiomatic and communicates intent.
Concept: Short variable declaration operator (:=) should be used for non-zero value initialization.
Why it matters: := declares AND initializes in one step — it signals the variable has a meaningful starting value, not just a zero.
package main
import "fmt"
func main() {
// := when value is known and non-zero at declaration time
name := "Harish"
port := 8080
ratio := 1.5
tags := []string{"go", "backend"}
fmt.Println(name, port, ratio, tags)
// := also used for multi-return unwrapping
result, err := fmt.Println("hello") // result and err declared + initialized
_ = result
_ = err
}
Gotcha: Accidentally shadowing an outer variable with := inside an if block — the outer variable remains unchanged.
Concept: Conversion is preferred over casting for type safety.
Why it matters: Conversion creates a new value of the target type safely; casting (via unsafe) reinterprets raw memory which can produce garbage values.
package main
import "fmt"
func main() {
var x int32 = 1000
// conversion: safe — new value created, compiler validates compatibility
y := int64(x)
z := float64(x)
fmt.Println(y, z)
// conversion with potential data loss — compiler allows it, you are responsible
var big int32 = 300
small := int8(big) // 300 overflows int8 (max 127) — result is 44
fmt.Println(small) // 44 — data lost, but behavior is defined
// never use unsafe.Pointer for numeric conversion — that is casting
}
Gotcha: Converting float64 to int truncates (doesn't round) — int(3.9) is 3, not 4.
Concept: Struct types are user-defined composite data types that allow developers to model data according to their needs.
Why it matters: Structs are the primary tool for modeling domain concepts; how you define them directly determines correctness and performance.
package main
import "fmt"
// model the domain — fields named for what they represent, not how they're stored
type Employee struct {
ID int
Name string
Department string
Salary float64
}
func main() {
// struct literal: all fields explicit — no magic positional arguments
e := Employee{
ID: 101,
Name: "Harish",
Department: "Engineering",
Salary: 120000.00,
}
fmt.Printf("%+v\n", e)
}
Gotcha: Using positional struct literals Employee{101, "Harish", "Engineering", 120000} — adding a field breaks every call site silently.
Concept: Structs are allocated in contiguous memory blocks.
Why it matters: Contiguous layout means the CPU can load the entire struct in one or two cache lines — random pointer chasing cannot achieve this.
package main
import (
"fmt"
"unsafe"
)
type Point struct {
X float64 // offset 0
Y float64 // offset 8
}
func main() {
p := Point{X: 1.5, Y: 2.5}
// both fields are contiguous — loading X into cache also loads Y
fmt.Printf("Point size = %d bytes\n", unsafe.Sizeof(p))
fmt.Printf("X offset = %d\n", unsafe.Offsetof(p.X))
fmt.Printf("Y offset = %d\n", unsafe.Offsetof(p.Y))
fmt.Printf("X addr = %p\n", &p.X)
fmt.Printf("Y addr = %p\n", &p.Y) // exactly 8 bytes after X
}
Gotcha: Storing *Point in a slice instead of Point breaks contiguity — each element is a pointer to a random heap location.
Concept: Memory alignment — certain data types must be stored at memory addresses that are multiples of their size.
Why it matters: Misaligned access causes the CPU to perform two memory reads for one value — some platforms trap on misalignment entirely.
package main
import (
"fmt"
"unsafe"
)
// misaligned struct — compiler inserts padding automatically
type Misaligned struct {
A bool // 1 byte, offset 0
// 7 bytes padding inserted here
B float64 // 8 bytes, must start at offset 8 (multiple of 8)
C bool // 1 byte, offset 16
// 7 bytes padding at end
} // total: 24 bytes
func main() {
var m Misaligned
fmt.Printf("size=%d\n", unsafe.Sizeof(m)) // 24
fmt.Printf("A offset=%d\n", unsafe.Offsetof(m.A)) // 0
fmt.Printf("B offset=%d\n", unsafe.Offsetof(m.B)) // 8
fmt.Printf("C offset=%d\n", unsafe.Offsetof(m.C)) // 16
}
Gotcha: Assuming unsafe.Sizeof(struct{A bool; B float64}{}) is 9 bytes — it's 16 due to alignment padding.
Concept: Padding — unused bytes inserted by the compiler within a struct to ensure proper memory alignment.
Why it matters: Padding wastes memory; reordering fields largest-to-smallest eliminates it without changing behavior.
package main
import (
"fmt"
"unsafe"
)
// wasteful: 24 bytes due to padding
type Wasteful struct {
A bool // 1 byte + 7 pad
B float64 // 8 bytes
C bool // 1 byte + 7 pad
}
// efficient: 10 bytes needed, 16 with minimal padding (float64 alignment)
type Efficient struct {
B float64 // 8 bytes, offset 0
A bool // 1 byte, offset 8
C bool // 1 byte, offset 9
// 6 bytes padding at end for struct alignment
}
func main() {
fmt.Printf("Wasteful: %d bytes\n", unsafe.Sizeof(Wasteful{})) // 24
fmt.Printf("Efficient: %d bytes\n", unsafe.Sizeof(Efficient{})) // 16
}
Gotcha: Reordering fields for memory without updating comments — reviewers won't understand why the order looks wrong.
Concept: Optimize struct field order for correctness (readability); reorder only if memory constraints require it.
Why it matters: Readability-first ordering groups related fields logically; memory optimization is a last resort, not a default.
package main
import "fmt"
// fields ordered for readability — related fields grouped together
type HTTPRequest struct {
Method string // what
URL string // where
Headers map[string]string // metadata
Body []byte // payload
Timeout int // control
}
func main() {
req := HTTPRequest{
Method: "POST",
URL: "/api/users",
Headers: map[string]string{"Content-Type": "application/json"},
Body: []byte(`{"name":"Harish"}`),
Timeout: 30,
}
fmt.Printf("method=%s url=%s\n", req.Method, req.URL)
}
Gotcha: Reordering struct fields without updating tests that rely on positional literals — silent breakage.
Concept: Type Compatibility — two types are compatible if they have the same underlying memory layout.
Why it matters: Go allows explicit conversion between structurally identical types, enabling type-safe domain modeling without runtime cost.
package main
import "fmt"
type Meters float64
type Feet float64
func main() {
var m Meters = 100.0
// explicit conversion required — types are distinct even if layout is identical
f := Feet(m * 3.28084)
fmt.Printf("%.2f meters = %.2f feet\n", m, f)
// compiler prevents accidental mixing
// var bad Feet = m // compile error: cannot use Meters as Feet
}
Gotcha: Using type Speed = float64 (alias) instead of type Speed float64 (new type) — aliases provide no type safety.
Concept: Anonymous Struct — a struct defined inline without a specific name.
Why it matters: Anonymous structs are ideal for one-off groupings (test tables, JSON responses) without polluting the package namespace.
package main
import "fmt"
func main() {
// anonymous struct: used once, no need for a named type
point := struct {
X, Y int
}{X: 10, Y: 20}
fmt.Println(point.X, point.Y)
// common use: table-driven test cases
cases := []struct {
input int
expected int
}{
{2, 4},
{3, 9},
{4, 16},
}
for _, c := range cases {
fmt.Printf("%d² = %d\n", c.input, c.input*c.input)
_ = c.expected
}
}
Gotcha: Using anonymous structs for types that appear in more than one place — create a named type instead for consistency.
Concept: Everything in Go is pass-by-value.
Why it matters: This single rule explains all of Go's memory semantics — there is no hidden sharing unless you explicitly use a pointer.
package main
import "fmt"
type Config struct{ Debug bool }
// function receives a COPY — mutation does not affect the caller
func configure(cfg Config) {
cfg.Debug = true // modifies the copy only
fmt.Println("inside:", cfg.Debug)
}
func main() {
c := Config{Debug: false}
configure(c)
fmt.Println("outside:", c.Debug) // still false — copy was modified, not original
}
Gotcha: Expecting map mutation to be invisible to the caller — maps are reference types, so the pointer (not the data) is copied.
Concept: Goroutine — an independent execution path, similar to a lightweight thread.
Why it matters: Goroutines are cheap (2KB stack) and multiplexed onto OS threads by the scheduler — the foundation of Go concurrency.
package main
import (
"fmt"
"sync"
)
// goroutines are cheap enough to create per-request — unlike OS threads
func handle(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("goroutine %d running\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go handle(i, &wg) // go keyword: launch goroutine, return immediately
}
wg.Wait()
}
Gotcha: Launching goroutines without tracking them — if main returns, all goroutines are killed instantly regardless of their state.
Concept: Stack — a memory region allocated for each goroutine to manage function calls and local variables; stacks grow dynamically.
Why it matters: Go's dynamically growing stack means you never set a stack size limit — goroutines start small (2KB) and grow as needed.
package main
import (
"fmt"
"runtime"
)
// deep recursion grows the stack automatically — no stack overflow in Go
func recurse(n int) int {
if n == 0 {
return 0
}
return n + recurse(n-1)
}
func main() {
fmt.Println(recurse(10000))
// goroutine stack starts at ~2KB — much smaller than OS thread (1-8MB)
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
fmt.Printf("goroutines: %d\n", runtime.NumGoroutine())
fmt.Printf("heap alloc: %d KB\n", stats.HeapAlloc/1024)
}
Gotcha: Storing a pointer to a stack variable and sharing it across goroutines — the stack may be relocated on growth, but Go handles this; the danger is conceptual confusion.
Concept: Frame — a segment on the stack dedicated to a specific function call; stores local variables, parameters, and return information.
Why it matters: Each function call creates a frame; understanding frames explains why local variables are "free" and why returning a pointer causes heap escape.
package main
import "fmt"
// each call to addFrame gets its own frame — a, b, result are local to that call
func addFrame(a, b int) int {
result := a + b // result lives in this frame — freed when function returns
return result // value is COPIED out of the frame to the caller's frame
}
func main() {
// three separate stack frames created and destroyed
x := addFrame(1, 2) // frame 1
y := addFrame(3, 4) // frame 2
z := addFrame(5, 6) // frame 3
fmt.Println(x, y, z)
}
Gotcha: Assuming a local variable persists after function return — it doesn't unless it escapes to the heap.
Concept: Value Semantics — functions operating on their own copy of data, preventing unintended modifications.
Why it matters: Value semantics are the safest default — no shared state means no data races and no unexpected side effects.
package main
import "fmt"
type Point struct{ X, Y float64 }
// value receiver: Scale works on a copy — original unchanged
func (p Point) Scale(factor float64) Point {
return Point{X: p.X * factor, Y: p.Y * factor} // returns new value
}
func main() {
original := Point{X: 3, Y: 4}
scaled := original.Scale(2)
fmt.Println("original:", original) // {3 4} — untouched
fmt.Println("scaled: ", scaled) // {6 8}
}
Gotcha: Choosing value semantics for a type containing a mutex — copying a mutex is a data race.
Concept: Pointer Semantics — functions sharing access to data using pointers, allowing modifications to directly affect the original data.
Why it matters: Pointer semantics are necessary for mutation, large structs (avoid copying), and types that must not be copied (mutexes, file handles).
package main
import "fmt"
type Account struct{ balance float64 }
// pointer receiver: Deposit modifies the original — caller sees the change
func (a *Account) Deposit(amount float64) {
a.balance += amount // writes through the pointer to the original
}
func (a *Account) Balance() float64 { return a.balance }
func main() {
acc := Account{}
acc.Deposit(100)
acc.Deposit(50)
fmt.Printf("balance: %.2f\n", acc.Balance()) // 150.00
}
Gotcha: Mixing value and pointer receivers on the same type — pick one semantic and apply it consistently.
Concept: Side Effects — changes in a program's state that occur as a result of function execution through pointers.
Why it matters: Unintended side effects are the root cause of most concurrency bugs and unexpected state mutations.
package main
import "fmt"
// side effect made explicit in the function signature via pointer parameter
func appendItem(items *[]string, item string) {
*items = append(*items, item) // side effect: mutates the caller's slice
}
func main() {
cart := []string{"apple"}
appendItem(&cart, "banana") // caller explicitly opts into the side effect
appendItem(&cart, "cherry")
fmt.Println(cart) // [apple banana cherry]
}
Gotcha: Returning a modified copy (value semantic) when the function signature promises mutation via pointer — the caller's original is unchanged, causing bugs.
Concept: Factory Function — a function designed to create and initialize a value of a specific type and return it.
Why it matters: Factory functions enforce valid construction — a zero-value struct may be in an invalid state that the factory prevents.
package main
import (
"errors"
"fmt"
)
type Server struct {
host string
port int
}
// factory: enforces invariants — no Server with empty host or invalid port can exist
func NewServer(host string, port int) (*Server, error) {
if host == "" {
return nil, errors.New("NewServer: host cannot be empty")
}
if port < 1 || port > 65535 {
return nil, fmt.Errorf("NewServer: invalid port %d", port)
}
return &Server{host: host, port: port}, nil
}
func main() {
s, err := NewServer("localhost", 8080)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Printf("server: %s:%d\n", s.host, s.port)
}
Gotcha: Exporting struct fields when using a factory — callers can bypass the factory and create invalid state directly.
Concept: Escape Analysis — a compiler analysis that determines if a value needs to be allocated on the heap.
Why it matters: Understanding escape analysis lets you write code that stays on the stack (fast, no GC) versus unnecessarily escaping to the heap (slow, GC pressure).
package main
import "fmt"
// stays on stack: value returned by copy — no escape
func stackValue() int {
x := 42
return x // x copied to caller's frame, original frame discarded
}
// escapes to heap: address of local variable returned — must outlive the frame
func heapValue() *int {
x := 42
return &x // x must survive after this frame is gone — heap allocated
}
func main() {
v := stackValue() // no heap allocation
p := heapValue() // heap allocation — GC must track *p
fmt.Println(v, *p)
}
// verify: go build -gcflags='-m' main.go
// output: "x escapes to heap"
Gotcha: Returning &localVar from a function thinking it saves a copy — it actually forces a heap allocation.
Concept: Sharing data up the call stack leads to escape to the heap.
Why it matters: "Sharing up" means a child function returns a pointer to data the parent must keep alive — the compiler moves it to the heap automatically.
package main
import "fmt"
// sharing UP: newUser shares its local variable with the caller above it
func newUser(name string) *struct{ Name string } {
u := struct{ Name string }{Name: name}
return &u // u shared UP the stack — escapes to heap
}
// sharing DOWN: main passes its variable down to print — stays on stack
func printUser(u *struct{ Name string }) {
fmt.Println(u.Name) // receives pointer from above — no escape here
}
func main() {
u := newUser("Harish") // heap allocated — newUser shared it up
printUser(u) // passed down — no additional escape
}
Gotcha: Thinking "sharing down" (passing a pointer into a function) causes heap escape — it doesn't, only sharing up does.
Concept: Use go build -gcflags '-m' to show escape analysis information.
Why it matters: Escape analysis output is the ground truth for allocation decisions — profiling without it is guesswork.
package main
import "fmt"
// run: go build -gcflags='-m -m' main.go to see escape decisions
type Config struct {
Name string
Port int
}
// stays on stack — no pointer returned, no interface conversion
func stackAlloc() Config {
return Config{Name: "local", Port: 8080}
}
// escapes — pointer returned, caller uses it after this frame is gone
func heapAlloc() *Config {
return &Config{Name: "heap", Port: 9090} // "Config literal escapes to heap"
}
func main() {
s := stackAlloc()
h := heapAlloc()
fmt.Println(s.Name, h.Name)
}
// go build -gcflags='-m' .
// look for: "escapes to heap" and "does not escape"
Gotcha: Running -gcflags='-m' on only one file instead of ./... — inter-package escapes are missed.
Concept: Go stacks are contiguous and can grow dynamically by doubling in size.
Why it matters: Contiguous stacks allow O(1) local variable access; doubling growth amortizes the copying cost.
package main
import (
"fmt"
"runtime"
)
// each goroutine starts with a small stack — grown automatically as needed
func deepCall(n int) int {
if n == 0 {
var s runtime.MemStats
runtime.ReadMemStats(&s)
fmt.Printf("stack sys: %d KB\n", s.StackSys/1024)
return 0
}
var padding [256]byte // force stack growth faster
_ = padding
return deepCall(n - 1)
}
func main() {
result := deepCall(100)
fmt.Println("result:", result)
}
Gotcha: Holding a pointer into a goroutine's stack from another goroutine — when the stack grows and moves, the pointer becomes invalid (Go's GC updates references, but unsafe.Pointer bypasses this).
Concept: Stack growth can involve moving data in memory and updating pointers.
Why it matters: Go handles pointer updates during stack growth transparently — this is why goroutine stacks can grow safely but unsafe.Pointer to stack memory is dangerous.
package main
import "fmt"
// Go transparently updates all pointers when the stack is moved — safe by default
func stackGrowth(n int, ptr *int) *int {
if n == 0 {
return ptr
}
local := n // local variable on this stack frame
return stackGrowth(n-1, &local) // pass pointer down — Go updates it if stack moves
}
func main() {
start := 42
p := stackGrowth(1000, &start)
fmt.Println(*p) // valid — Go updated the pointer when the stack was copied
}
Gotcha: Using unsafe.Pointer to cache a stack address across function calls — the cached address becomes stale after stack growth.
Concept: Goroutines cannot share data on their stacks with other goroutines.
Why it matters: Stack isolation prevents goroutines from accidentally corrupting each other's local state.
package main
import (
"fmt"
"sync"
)
// wrong: trying to share a stack address across goroutines
func wrongSharing(wg *sync.WaitGroup) {
defer wg.Done()
x := 42
// sending &x to another goroutine is dangerous — x is on this goroutine's stack
// if this goroutine returns, x is gone; the receiver holds a dangling pointer
fmt.Println(x) // safe: used only within this goroutine's scope
}
// correct: heap-allocate data that must be shared between goroutines
func correctSharing(shared *int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println(*shared) // safe: shared is on the heap, not any goroutine's stack
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go wrongSharing(&wg)
heapVal := 99 // escapes to heap because address is shared
wg.Add(1)
go correctSharing(&heapVal, &wg)
wg.Wait()
}
Gotcha: Taking the address of a loop variable and passing it to a goroutine — by the time the goroutine runs, the loop variable has changed.
Concept: Sharing values between goroutines necessitates heap allocation.
Why it matters: The heap is the only memory region accessible by all goroutines — stack memory is goroutine-private.
package main
import (
"fmt"
"sync"
)
// heap-allocated result shared safely between producer and consumer goroutines
func produce(wg *sync.WaitGroup) *[]int {
results := make([]int, 0, 5) // make allocates on heap — safe to share
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 5; i++ {
results = append(results, i*i)
}
}()
return &results // heap pointer — valid after goroutine runs
}
func main() {
var wg sync.WaitGroup
rp := produce(&wg)
wg.Wait()
fmt.Println(*rp) // [0 1 4 9 16] — heap data outlives producer goroutine
}
Gotcha: Sharing a heap-allocated value across goroutines without synchronization — heap allocation alone doesn't prevent data races.
Concept: The garbage collector reclaims unused memory on the heap.
Why it matters: The GC is your memory manager — understanding when it runs and what it costs lets you write GC-friendly code.
package main
import (
"fmt"
"runtime"
)
func main() {
// force a GC cycle and observe heap before/after
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
// create heap garbage
for i := 0; i < 10000; i++ {
_ = make([]byte, 1024) // 1KB allocations, immediately unreferenced
}
runtime.GC() // GC reclaims the unreferenced allocations
runtime.ReadMemStats(&after)
fmt.Printf("GC cycles: %d\n", after.NumGC)
fmt.Printf("heap after GC: %d KB\n", after.HeapAlloc/1024)
}
Gotcha: Calling runtime.GC() in production to "help" performance — it pauses all goroutines and should be left to the runtime's pacer.
Concept: Go's garbage collector is concurrent — operates while the application runs.
Why it matters: Concurrent GC means your program doesn't fully stop while memory is reclaimed — latency spikes are minimized.
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
// concurrent GC: application goroutines and GC goroutines run simultaneously
// observe with: GODEBUG=gctrace=1 go run main.go
done := make(chan struct{})
go func() {
// this goroutine keeps running during GC — concurrent collector
count := 0
for {
select {
case <-done:
fmt.Printf("counted %d iterations during GC\n", count)
return
default:
count++
}
}
}()
// generate heap garbage to trigger GC
for i := 0; i < 100000; i++ {
_ = make([]byte, 1024)
}
time.Sleep(10 * time.Millisecond)
close(done)
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
fmt.Printf("NumGC: %d\n", stats.NumGC)
}
Gotcha: Assuming concurrent GC means zero impact — it still uses CPU and causes short stop-the-world pauses.
Concept: The pacer determines when to start a garbage collection cycle.
Why it matters: The pacer uses GOGC (default 100%) to decide when to trigger GC — tuning it trades memory for CPU.
package main
import (
"fmt"
"os"
"runtime"
)
func main() {
// pacer triggers GC when heap doubles from last collection
// GOGC=100 (default): heap can double before GC runs
// GOGC=50: GC runs more often, lower memory, more CPU
// GOGC=200: GC runs less often, more memory, less CPU
fmt.Printf("GOGC env: %s\n", os.Getenv("GOGC")) // "" means default 100
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
fmt.Printf("NextGC target: %d KB\n", stats.NextGC/1024)
fmt.Printf("HeapAlloc: %d KB\n", stats.HeapAlloc/1024)
// NextGC ≈ HeapAlloc * (1 + GOGC/100)
}
// tune: GOGC=50 go run main.go (latency-sensitive)
// tune: GOGC=400 go run main.go (throughput-focused, more memory OK)
Gotcha: Setting GOGC=off in production to eliminate GC — the heap grows unbounded until the process is OOM-killed.
Concept: Stop-the-world pauses — Go aims to keep them under 100 microseconds.
Why it matters: STW pauses block ALL goroutines — even 1ms pauses cause P99 latency spikes in high-throughput services.
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
// measure the actual STW pause your program experiences
var maxPause time.Duration
go func() {
for {
before := time.Now()
runtime.Gosched() // yield — measures scheduler + GC latency
pause := time.Since(before)
if pause > maxPause {
maxPause = pause
}
}
}()
// generate GC pressure
for i := 0; i < 1000000; i++ {
_ = make([]byte, 128)
}
time.Sleep(100 * time.Millisecond)
fmt.Printf("observed max pause: %v\n", maxPause)
// target: < 100µs for low-latency services
}
Gotcha: Measuring application latency with time.Since and ignoring STW pauses — the tail latency spike is invisible without tracing.
Concept: Heap Pressure — how quickly the heap is being filled; high pressure leads to more frequent GC cycles.
Why it matters: Every unnecessary allocation increases heap pressure, which increases GC frequency, which steals CPU from your application.
package main
import (
"fmt"
"runtime"
"strings"
)
// high pressure: allocates on every call
func joinHigh(parts []string) string {
result := ""
for _, p := range parts {
result += p // new string allocation every iteration
}
return result
}
// low pressure: single allocation via Builder
func joinLow(parts []string) string {
var b strings.Builder
for _, p := range parts {
b.WriteString(p) // writes to pre-allocated buffer
}
return b.String() // one allocation
}
func main() {
parts := make([]string, 1000)
for i := range parts {
parts[i] = "x"
}
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
for i := 0; i < 1000; i++ {
_ = joinHigh(parts)
}
runtime.ReadMemStats(&after)
fmt.Printf("high pressure allocs: %d\n", after.Mallocs-before.Mallocs)
}
Gotcha: Ignoring heap pressure in initialization code — startup allocations are one-time, but per-request allocations compound.
Concept: Constants in Go are not variables — they exist only at compile time.
Why it matters: Constants are substituted at compile time — they have no runtime memory address and impose zero runtime cost.
package main
import "fmt"
const (
MaxRetries = 3
Timeout = 30 // seconds
AppName = "treasury-data-hub"
)
func main() {
fmt.Println(MaxRetries, Timeout, AppName)
// constants have no address — this would be a compile error:
// p := &MaxRetries // invalid: cannot take address of constant
// constants are evaluated at compile time
const result = 1<<10 + 2<<10 // computed at compile time, not runtime
fmt.Println(result) // 3072
}
Gotcha: Using var instead of const for truly fixed values — var allocates memory and can be accidentally mutated.
Concept: Typed constants adhere to the constraints of their declared type; untyped constants have minimum precision of 256 bits.
Why it matters: Untyped constants avoid precision loss in arithmetic — they carry full mathematical precision until assigned to a variable.
package main
import "fmt"
// typed constant: constrained to float64 precision
const TypedPi float64 = 3.14159265358979323846
// untyped constant: 256-bit precision at compile time — no truncation yet
const UntypedPi = 3.14159265358979323846264338327950288
func main() {
// untyped adapts to the context it's used in
var f32 float32 = UntypedPi // truncated to float32 precision at assignment
var f64 float64 = UntypedPi // truncated to float64 precision at assignment
fmt.Printf("float32: %.10f\n", f32)
fmt.Printf("float64: %.20f\n", f64)
}
Gotcha: Declaring const x float64 = 1/3 — integer division happens first, result is 0; use const x = 1.0/3.0.
Concept: Kind promotion — float promotes over int for untyped constants.
Why it matters: Kind promotion allows mixed arithmetic on untyped constants without explicit conversions — the result takes the most capable kind.
package main
import "fmt"
func main() {
// untyped int + untyped float = untyped float (float promotes)
const a = 1 // untyped int kind
const b = 1.5 // untyped float kind
const c = a + b // c is untyped float kind — a was promoted
fmt.Println(c) // 2.5
// untyped constant assigned to typed variable — kind must be compatible
var x float64 = c // ok: untyped float → float64
var y int = a // ok: untyped int → int (a is exactly representable)
// var z int = b // compile error: constant 1.5 truncates to int
fmt.Println(x, y)
}
Gotcha: Assuming const x = 1 / 2 gives 0.5 — both operands are untyped int kind, result is 0; write 1.0 / 2.
Concept: Iota — a powerful tool for creating blocks of related constants, automatically incrementing with each declaration.
Why it matters: iota eliminates manual enumeration errors and makes the relationship between constants explicit and maintainable.
package main
import "fmt"
type Weekday int
const (
Sunday Weekday = iota // 0
Monday // 1
Tuesday // 2
Wednesday // 3
Thursday // 4
Friday // 5
Saturday // 6
)
func (d Weekday) String() string {
names := [...]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
if d < Sunday || d > Saturday {
return fmt.Sprintf("Weekday(%d)", d)
}
return names[d]
}
func main() {
fmt.Println(Wednesday) // Wednesday (not 3)
fmt.Println(int(Friday)) // 5
}
Gotcha: Using iota in separate const blocks — iota resets to 0 in each new const block.
Concept: Iota especially useful for defining bitmaps or sequences of integer-based constants.
Why it matters: Bit-shifting iota creates power-of-two flag values — each constant represents a single bit, enabling efficient bitwise flag combinations.
package main
import "fmt"
type Permission uint
const (
Read Permission = 1 << iota // 1 (001)
Write // 2 (010)
Execute // 4 (100)
)
func (p Permission) String() string {
s := ""
if p&Read != 0 { s += "r" } else { s += "-" }
if p&Write != 0 { s += "w" } else { s += "-" }
if p&Execute != 0 { s += "x" } else { s += "-" }
return s
}
func main() {
// combine permissions with bitwise OR
userPerm := Read | Write
fmt.Println(userPerm) // rw-
// test a specific permission with bitwise AND
fmt.Println(userPerm&Execute != 0) // false — no execute
fmt.Println(userPerm&Read != 0) // true
}
Gotcha: Combining non-power-of-two iota values with bitwise OR — overlapping bits produce incorrect combined values.