Data Types & Memory Model Best Practices
Choosing types and memory layouts in Go balances readability, correctness under concurrency, and measured performance. These rules turn the internals articles in this section into day-to-day engineering habits.
How to Use This List
- Apply during API design, hot-path refactors, and code review for memory-related bugs.
- Pair with profiles (
pprof) before changing types for speed. - Revisit when upgrading Go - runtime map, slice, and GC behavior evolves.
A - Type Choice and APIs
- Return concrete types, accept interfaces. Callers stay decoupled; producers expose clear zero values and constructors.
- Use value semantics for small records (
time.Time, IDs, coordinates). Avoid hidden mutation and nil checks. - Use pointers for large structs or in-place mutation. Document ownership - who allocates, who frees logically via GC.
- Prefer
[]Tover arrays in public APIs unless size is protocol-fixed. Arrays complicate generics and function boundaries. - Use
map[K]Vonly with comparable keys; never float keys. Sets usemap[T]struct{}for clarity.
B - Slices and Maps
- Preallocate with
make([]T, 0, n)andmake(map[K]V, n)when size is known. Cuts growth and rehash cost in loaders and parsers. - Copy or full-slice when handing subslices to callers. Prevent
appendfrom mutating your internal buffers. - Never write to nil maps; initialize with
makeor literals. Nil reads are fine; writes panic. - Do not rely on map iteration order. Sort keys when presenting ordered output.
- Clone maps for snapshots (
maps.Clone) before async processing. Avoid races on shared map mutations.
C - Interfaces and Errors
- Return
nilerror, not typed nil pointers, from constructors.err == nilmust mean success. - Use type assertions with comma-ok or type switches on
any. Prevent panics on unexpected dynamic types. - Keep error types as values when possible. Pointer receivers on errors recreate typed-nil interface bugs.
- Limit
interface{}at boundaries; decode to concrete types early. Narrow scope of dynamic dispatch and reflection costs. - Document which methods use pointer vs value receivers. Determines which types satisfy interfaces.
D - Concurrency and Memory Safety
- Protect maps with mutexes or use
sync.Mapfor specific caches. Concurrent map writes are undefined. - Run
go test -raceon packages sharing memory between goroutines. Catches unsynchronized slice/map/pointer access. - Sender closes channels; document shutdown protocol. Receivers use
rangeorokto exit cleanly. - Pass ownership via channel send or clear pointers after handoff. Reduce double-free logical bugs and races.
- Use
context.Contextfor cancel, not ad-hoc bool flags alone. Pair with channel/select shutdown paths.
E - Layout, unsafe, and Performance
- Reorder struct fields only with
unsafe.Sizeoftests and ABI awareness. Padding changes affect FFI and on-disk formats. - Reach for
unsafeonly after benchmarks prove need. Isolate in small functions with documented invariants. - Check escape analysis (
-gcflags=-m) after allocation regressions. Unexpected heap traffic often starts at pointer escapes. - Reuse buffers with
sync.Poolfor transient slices, not long-lived state. Avoid retaining large backing arrays accidentally. - Validate layout and atomics on 32-bit CI if you ship multi-arch. Alignment bugs appear only on some GOARCH builds.
FAQs
Should I default to pointers or values for structs?
Values for small immutable records; pointers for large or mutable shared state. When unsure, start with values and profile hot paths.
What is the top slice mistake?
Sharing subslices with spare capacity, then appending into caller-owned memory. Copy or use three-index slicing.
What is the top map mistake?
Concurrent writes without synchronization. Use mutex sharding or pass snapshots to worker goroutines.
What is the top interface mistake?
Returning typed nil pointers as error. Return bare nil on success paths from constructors.
When is unsafe acceptable?
cgo/syscall interop, proven zero-copy bottlenecks, and stdlib-style primitives with tests per platform - never for convenience.
How do I choose channel buffer size?
Size to the burst you want to absorb without unbounded memory; default unbuffered when synchronization is the goal.
Should I use `new` or `make`?
make for slices, maps, channels; literals or var for structs. new is rare in idiomatic Go application code.
How do I shrink memory of a long-lived map?
Replace with a new map periodically or scope maps to request/generation lifetime instead of unbounded growth.
Are empty slices and nil slices equivalent?
Often for len/append, but JSON and some APIs differ. Use make([]T, 0) when you need non-nil empty semantics.
What CI checks cover this list?
go test -race, go vet, golangci-lint, and periodic alloc/cpu profiles on representative workloads.
Do generics remove need for interfaces?
Generics reduce hot-path interface boxing; interfaces remain for heterogeneous plugin boundaries and stdlib patterns.
Where do I learn internals after this list?
Read the section articles on slices, maps, interfaces, channels, and the memory model explainer in order.
Related
- Go's Memory Model - value semantics foundation
- Slice Internals - append and aliasing details
- Map Internals - growth and iteration
- Interface Internals - nil interface behavior
- Escape Analysis - allocation tuning
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).