The Go Idioms You Actually Reach For: A Golang Cheat Sheet
A practical Go cheat sheet for the idioms you type daily — structs, slices, maps, goroutines, channels, defer, and the if err != nil pattern, with real snippets.
The Go Idioms You Actually Reach For: A Golang Cheat Sheet
Most Go tutorials teach you the language. What they rarely teach is the muscle memory — the five or six patterns your fingers type without thinking once you have shipped a couple of services. After a while you stop looking things up and start reaching for the same shapes: a struct with tags, a slice you grow with append, a map you range over, a goroutine fed by a channel, and the if err != nil block stamped at every call site.
This is a quick reference for exactly those idioms. If you want the searchable, filterable version with 100+ snippets, the Go (Golang) Cheatsheet has every entry split into twelve sections. This post walks through the handful you reach for most.
Structs, slices, and maps: the data you carry around
A Go program is mostly three containers. Structs group fields, slices hold ordered values, and maps hold keyed ones. Here is the shape of all three:
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Admin bool `json:"admin"`
}
users := []User{} // empty slice, len 0
users = append(users, User{ID: 1, Name: "Lei"})
byID := make(map[int]User) // empty, ready map
byID[1] = users[0]
u, ok := byID[1] // comma-ok: ok is false if absent
if !ok {
// key was missing
}
Three things trip people up. First, the struct tag — ` json:"id" — is what encoding/json reads to name fields in output. Second, a map read with one variable gives you the zero value when the key is missing, which is indistinguishable from a stored zero; always use the comma-ok form when absence matters. Third, append may reuse the backing array, so two slices sharing one array can write through each other. When that matters, allocate a fresh slice with make([]User, 0, n)` and a known capacity.
Ranging over either container is the same keyword:
for i, u := range users { // index, value
fmt.Println(i, u.Name)
}
for id, u := range byID { // key, value (unordered)
fmt.Println(id, u.Name)
}
Map iteration order is randomized on purpose. If you need stable output, pull the keys into a slice and sort them.
if err != nil: the most-typed block in Go
Go has no exceptions. Functions return an error as their last value, and you check it right there. This is the line you will write more than any other:
data, err := os.ReadFile("config.json")
if err != nil {
return fmt.Errorf("reading config: %w", err)
}
The %w verb wraps the original error so callers can inspect it later with errors.Is and errors.As:
if errors.Is(err, os.ErrNotExist) {
// the file simply was not there
}
One trap worth burning into memory: never return a typed nil pointer where the signature says error. An interface value is a (type, value) pair and only equals nil when both halves are nil. Write var p *MyError = nil; return p and the caller's if err != nil fires even though the pointer is nil. In the happy path, always return the bare keyword nil.
defer: cleanup that runs no matter what
defer schedules a call to run when the surrounding function returns, in last-in-first-out order. It is how you guarantee a file handle, a lock, or a network body gets released even on an early return or a panic:
f, err := os.Open("data.csv")
if err != nil {
return err
}
defer f.Close()
// ...read from f; Close runs on every exit path
The same pattern guards HTTP response bodies (defer resp.Body.Close()) and mutexes (defer mu.Unlock()). Two cautions: arguments to a deferred call are evaluated immediately, not at return time, and a defer inside a loop does not run until the function exits — so deferring Close() on a thousand files in one loop will exhaust your file descriptors. In a loop, close explicitly or wrap the body in its own function.
Goroutines and channels: doing work concurrently
A goroutine is a function call with go in front. A channel, made with make(chan T), is a typed pipe that goroutines use to hand values to each other safely. The combination is why people pick Go.
go func() {
fmt.Println("runs in the background")
}()
ch := make(chan int)
go func() { ch <- 42 }() // send blocks until received
result := <-ch // receive blocks until sent
An unbuffered channel synchronizes the two goroutines: the send waits for the receive. A buffered one (make(chan int, 8)) lets a few values queue before the sender blocks. The closing idiom matters too — the sender closes the channel, and a receiver can detect that with the comma-ok form or a range:
for v := range ch { // loops until ch is closed and drained
process(v)
}
A worked scenario: a worker pool for a 50,000-job backlog
Say you have fifty thousand images to resize and a single loop is hopelessly slow. The idiomatic answer is a worker pool: one channel feeds jobs, a fixed number of goroutines drain it, and a sync.WaitGroup tells you when the work is done.
func main() {
jobs := make(chan string, 100)
var wg sync.WaitGroup
// Start 8 workers, one per core.
for w := 0; w < 8; w++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for path := range jobs { // each worker pulls until jobs closes
resize(path)
}
}(id)
}
// Feed every job, then close so workers know to stop.
for _, path := range imagePaths {
jobs <- path
}
close(jobs)
wg.Wait() // block until all 8 workers return
fmt.Println("done")
}
The whole correctness of this hinges on two lines. close(jobs) is what lets each worker's for ... range jobs loop end — without it, the workers block forever and wg.Wait() never returns. And defer wg.Done() guarantees the counter drops even if resize panics. Pass the worker index as an argument to the closure rather than capturing the loop variable, which on Go before 1.22 would have every worker see the final value.
I learned the cost of skipping that close the hard way. I once shipped a pool that processed a batch fine in tests but hung in production whenever the producer finished early — the workers were parked on a channel that nobody ever closed, and wg.Wait() sat there until the deploy timed out. The fix was one line. Ever since, I write close(jobs) on the same screen as the loop that fills it, so the two can never drift apart.
Where to go from here
Those five idioms — structs and maps, if err != nil, defer, goroutines, and channels — cover the bulk of day-to-day Go. The rest is depth: context propagation, generics, the standard library, and a list of pitfalls that cost real time. All of it lives in the Go (Golang) Cheatsheet, searchable across title, code, and description.
If Go is one stop on a wider stack, the same searchable format covers the neighbors: the TypeScript cheat sheet for the front end, the PostgreSQL cheat sheet for the database layer your Go service talks to, and the Python cheat sheet when you are porting a service across. Keep one tab open while you write, and you stop breaking flow to look up the obvious.
Made by Toolora · Updated 2026-06-13