Skip to content

Instantly share code, notes, and snippets.

@jonlabelle
Last active February 27, 2026 13:10
Show Gist options
  • Select an option

  • Save jonlabelle/0f24799606ec5cc2e6199f1a6cbbaf75 to your computer and use it in GitHub Desktop.

Select an option

Save jonlabelle/0f24799606ec5cc2e6199f1a6cbbaf75 to your computer and use it in GitHub Desktop.
Go language cheatsheet, written to be something you can actually keep open while coding.

Go (Golang) Language Cheatsheet

Comprehensive Go language cheatsheet, written to be something you can actually keep open while coding.

It includes core syntax, language rules, standard patterns, and essential "Go-isms".


Table of Contents

  1. Program Structure
  2. Building & Running
  3. Variables & Constants
  4. Primitive Types
  5. Type Conversions & Assertions
  6. Operators
  7. Strings
  8. Control Flow
  9. Functions
  10. Closures
  11. Pointers
  12. Arrays
  13. Slices
  14. Maps
  15. Structs
  16. Embedding
  17. Methods & Receivers
  18. Interfaces
  19. Generics
  20. Error Handling
  21. defer, panic, recover
  22. init Functions
  23. Concurrency
  24. Channels
  25. Context
  26. Packages & Modules
  27. Visibility & Naming
  28. Memory & Zero Values
  29. Testing
  30. Common Standard Library
  31. Example Applications
  32. Common Gotchas
  33. Idiomatic Go Rules

Program Structure

package main // every executable must be in package main

import (
    "fmt"  // grouped imports (preferred over multiple import statements)
    "os"
)

func main() { // entry point — takes no args, returns nothing
    fmt.Println("Hello, Go")
    os.Exit(0) // explicit exit code (rarely needed; 0 is default)
}
  • Every executable has package main and func main()
  • Imports are explicit — unused imports are a compile error
  • Use _ to import for side effects only: import _ "net/http/pprof"

Building & Running

Run Without Compiling

go run main.go                # compile + run in one step (no binary produced)
go run .                      # run the package in the current directory

Build a Binary

go build                      # builds binary named after module (in current dir)
go build -o myapp             # specify output binary name
go build -o bin/myapp ./cmd/server  # build a specific sub-package

Install Globally

go install                    # builds and places binary in $GOPATH/bin (or $GOBIN)
go install example.com/tool@latest  # install a remote tool

Cross-Compilation

# Go cross-compiles with just two env vars — no extra toolchain needed
GOOS=linux GOARCH=amd64 go build -o myapp-linux
GOOS=darwin GOARCH=arm64 go build -o myapp-mac
GOOS=windows GOARCH=amd64 go build -o myapp.exe

Common GOOS/GOARCH combos:

GOOS GOARCH Target
linux amd64 Linux x86-64
linux arm64 Linux ARM (e.g., AWS Graviton)
darwin amd64 macOS Intel
darwin arm64 macOS Apple Silicon
windows amd64 Windows x86-64

Build Tags

//go:build linux
// +build linux    // old syntax (pre-1.17), still works

package mypackage
go build -tags "integration"    # include files with //go:build integration

Embed Version Info at Build Time

// main.go
var version = "dev" // default value
go build -ldflags "-X main.version=1.2.3" -o myapp
# sets the version variable at compile time

Build Flags Summary

Flag Purpose
-o name output binary name
-v verbose (print packages being compiled)
-race enable race detector
-ldflags "-s -w" strip debug info (smaller binary)
-ldflags "-X pkg.Var=val" inject string variable at build time
-tags "tag1 tag2" enable build tags
-trimpath remove local file paths from binary

What go build Produces

  • A single static binary — no runtime dependencies, no VM, no interpreter
  • Includes the Go runtime and garbage collector
  • Typical binary size: 5-15 MB (use -ldflags "-s -w" to shrink)
  • Binary is ready to deploy — just copy it to the target machine

Variables & Constants

Short Declaration (inside functions only)

x := 10             // type inferred as int
name := "Jon"        // type inferred as string
a, b := 1, "hello"  // multiple assignment

Explicit Declaration (anywhere)

var x int = 10   // explicit type + value
var y = 20       // type inferred
var z int        // zero-initialized (z == 0)
var a, b int     // multiple vars, both zero

Package-Level Variables

var Version = "1.0.0" // exported (uppercase)
var debug = false      // unexported (lowercase)

Constants

const Pi = 3.14159    // untyped constant — higher precision until assigned
const Timeout = 5 * time.Second

const (
    StatusOK    = 200
    StatusNotOK = 400
)

Iota (auto-incrementing constant generator)

const (
    Read    = 1 << iota // 1
    Write               // 2
    Execute             // 4
)

const (
    Sunday = iota // 0
    Monday        // 1
    Tuesday       // 2
)
  • Constants must be compile-time evaluable
  • No const for slices, maps, or structs
  • Untyped constants adapt to context: const x = 5 works as int, float64, etc.

Primitive Types

Numeric

int, int8, int16, int32, int64       // signed integers
uint, uint8, uint16, uint32, uint64  // unsigned integers
float32, float64                     // IEEE 754 floats
complex64, complex128                // complex numbers
uintptr                              // pointer-sized unsigned int (unsafe)

int and uint are platform-dependent: 32-bit on 32-bit systems, 64-bit on 64-bit.

Aliases

byte // alias for uint8 — used for raw data
rune // alias for int32 — represents a Unicode code point

Other

bool   // true or false (zero value: false)
string // immutable UTF-8 byte sequence (zero value: "")

Type Sizes

Type Size Range
int8 1 byte -128 to 127
int16 2 bytes -32,768 to 32,767
int32 4 bytes -2.1B to 2.1B
int64 8 bytes ±9.2 quintillion
float32 4 bytes ~7 decimal digits precision
float64 8 bytes ~15 decimal digits

Type Conversions & Assertions

Type Conversion (between compatible types)

i := 42
f := float64(i)    // int → float64
u := uint(f)       // float64 → uint
s := string(rune(65)) // int → rune → string ("A")

Go has no implicit conversions. Even int32int64 requires explicit cast.

String ↔ Number

import "strconv"

s := strconv.Itoa(42)          // int → string: "42"
i, err := strconv.Atoi("42")   // string → int: 42
f, err := strconv.ParseFloat("3.14", 64) // string → float64

Type Assertion (interfaces only)

var x any = "hello"

s := x.(string)        // panics if x is not a string
s, ok := x.(string)    // safe — ok is false if wrong type

if s, ok := x.(string); ok {
    fmt.Println(s) // use s safely
}

Type Switch

switch v := x.(type) {
case int:
    fmt.Println("int:", v)
case string:
    fmt.Println("string:", v)
case nil:
    fmt.Println("nil")
default:
    fmt.Printf("unknown: %T\n", v)
}

Operators

Arithmetic

+  -  *  /  %   // add, subtract, multiply, divide, modulo
++  --           // increment/decrement (statement only, not expression)

i++ is a statement. You cannot write x = i++.

Comparison

==  !=  <  <=  >  >=

Logical

&&  ||  !  // AND, OR, NOT (short-circuit evaluation)

Bitwise

&   // AND
|   // OR
^   // XOR (also unary NOT)
<<  // left shift
>>  // right shift
&^  // AND NOT (bit clear)

Assignment

=  :=  +=  -=  *=  /=  %=  &=  |=  ^=  <<=  >>=
  • ❌ No ternary operator (? :) — use if/else
  • ❌ No operator overloading

Strings

s := "hello, world"       // double quotes for interpreted strings
r := `raw \n string`       // backticks for raw strings (no escape processing)

Common Operations

len(s)                     // byte length (not rune count!)
utf8.RuneCountInString(s)  // actual character count
s[0]                       // byte at index (not rune!)
s[1:4]                     // substring (byte indices)
s + " suffix"              // concatenation (creates new string)

Iterating

for i, r := range s {     // r is a rune, i is byte index
    fmt.Printf("%d: %c\n", i, r)
}

// to iterate bytes:
for i := 0; i < len(s); i++ {
    fmt.Println(s[i])
}

strings Package

import "strings"

strings.Contains(s, "llo")       // true
strings.HasPrefix(s, "hel")      // true
strings.HasSuffix(s, "rld")      // true
strings.Index(s, "lo")           // 3
strings.ToUpper(s)                // "HELLO, WORLD"
strings.ToLower(s)                // "hello, world"
strings.TrimSpace("  hi  ")      // "hi"
strings.Split("a,b,c", ",")      // []string{"a", "b", "c"}
strings.Join([]string{"a","b"}, "-") // "a-b"
strings.ReplaceAll(s, "l", "L")  // "heLLo, worLd"
strings.Repeat("ha", 3)          // "hahaha"

Efficient String Building

var b strings.Builder   // use Builder for many concatenations
b.WriteString("hello")
b.WriteString(" world")
s := b.String()         // "hello world"

Strings are immutable. Every concatenation allocates a new string. Use strings.Builder or bytes.Buffer in loops.


Control Flow

If / Else

if x > 0 {
    // positive
} else if x == 0 {
    // zero
} else {
    // negative
}

With initializer (scoped to if/else block):

if v, err := strconv.Atoi(s); err == nil {
    fmt.Println(v)
} else {
    fmt.Println("error:", err)
}
// v and err are NOT accessible here

For (the only loop keyword)

// classic C-style
for i := 0; i < 10; i++ {}

// while-style
for condition {}

// infinite loop
for {
    break    // exit loop
    continue // skip to next iteration
}

Range (iterate over collections)

for i, v := range slice {}    // index + value
for _, v := range slice {}    // value only
for i := range slice {}       // index only

for k, v := range aMap {}     // key + value
for k := range aMap {}        // key only

for i, r := range "hello" {} // i = byte index, r = rune
for range 5 {}                // Go 1.22+: repeat 5 times

Labels & Break/Continue

outer:
    for i := 0; i < 3; i++ {
        for j := 0; j < 3; j++ {
            if j == 1 {
                continue outer // skip to next outer iteration
            }
            if i == 2 {
                break outer    // exit both loops
            }
        }
    }

Switch

switch x {
case 1:
    fmt.Println("one")   // no fallthrough by default (unlike C)
case 2, 3:
    fmt.Println("two or three")   // multiple values
    fallthrough           // explicit fallthrough to next case
case 4:
    fmt.Println("falls here from 2 or 3")
default:
    fmt.Println("other")
}

Expression-less switch (cleaner than if/else chains):

switch {
case x > 100:
    fmt.Println("big")
case x > 10:
    fmt.Println("medium")
default:
    fmt.Println("small")
}

Functions

func add(a int, b int) int {    // basic function
    return a + b
}

func add(a, b int) int {        // shorthand when types match
    return a + b
}

Multiple Returns

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

result, err := divide(10, 3)

Named Returns (use judiciously — can hurt readability)

func divide(a, b float64) (result float64, err error) {
    if b == 0 {
        err = errors.New("division by zero")
        return // "naked return" — returns named values
    }
    result = a / b
    return
}

Variadic Functions

func sum(nums ...int) int {   // nums is []int
    total := 0
    for _, n := range nums {
        total += n
    }
    return total
}

sum(1, 2, 3)
sum(nums...)    // spread a slice

First-Class Functions

var fn func(int) int            // function variable
fn = func(x int) int { return x * 2 }
fmt.Println(fn(5))              // 10

// as parameter
func apply(f func(int) int, x int) int {
    return f(x)
}

Closures

func counter() func() int {
    n := 0                      // captured by the closure
    return func() int {
        n++                     // mutates the captured variable
        return n
    }
}

c := counter()
fmt.Println(c()) // 1
fmt.Println(c()) // 2
fmt.Println(c()) // 3

⚠️ Loop variable capture pitfall:

// WRONG — all goroutines share the same `i`
for i := 0; i < 5; i++ {
    go func() { fmt.Println(i) }() // likely prints "5" five times
}

// CORRECT (Go <1.22) — pass as argument
for i := 0; i < 5; i++ {
    go func(i int) { fmt.Println(i) }(i)
}

// Go 1.22+ — loop vars are per-iteration by default (fixed!)

Pointers

x := 42
p := &x         // p is *int, points to x
fmt.Println(*p)  // 42 — dereference to read
*p = 100         // modify x through the pointer
fmt.Println(x)   // 100

Why Use Pointers?

// 1. Mutate the caller's data
func double(n *int) {
    *n *= 2
}

x := 5
double(&x)
fmt.Println(x) // 10

// 2. Avoid copying large structs
func process(u *User) { /* works on original, no copy */ }

// 3. Signal "optional" values (nil means absent)
func find(id int) *User {
    if id == 0 {
        return nil
    }
    return &User{ID: id}
}
  • ❌ No pointer arithmetic (use unsafe package if you must — you shouldn't)
  • new(T) allocates zeroed T and returns *T: p := new(int) // *int, value 0
  • Pointers to local variables are fine — Go's escape analysis handles it

Arrays

var a [3]int             // [0, 0, 0] — zero-valued
b := [3]int{1, 2, 3}    // [1, 2, 3]
c := [...]int{1, 2, 3}  // compiler counts: [3]int
  • Fixed size — size is part of the type: [3]int ≠ [4]int
  • Value types — assignment and function args copy the entire array
  • Rarely used directly; use slices instead

Slices

A slice is a reference to a contiguous segment of an array: (pointer, length, capacity).

s := []int{1, 2, 3}     // slice literal (no size = slice, not array)
s := make([]int, 5)      // len=5, cap=5, all zeros
s := make([]int, 0, 10)  // len=0, cap=10 (pre-allocate)

Append

s = append(s, 4)         // may allocate a new backing array
s = append(s, 5, 6, 7)   // append multiple
s = append(s, other...)   // append another slice

Slice Expressions

a := []int{0, 1, 2, 3, 4}
b := a[1:3]  // [1, 2] — shares backing array with a
c := a[:3]   // [0, 1, 2]
d := a[2:]   // [2, 3, 4]
e := a[:]    // full slice (shallow copy of header, same backing array)

Full Slice Expression (limit capacity)

b := a[1:3:3] // [1, 2] — cap=2, prevents append from overwriting a's data

Copy

dst := make([]int, len(src))
n := copy(dst, src)   // returns number of elements copied (min of both lens)

Delete Element (order-preserving)

s = append(s[:i], s[i+1:]...) // remove element at index i
s = slices.Delete(s, i, i+1)  // Go 1.21+ (preferred)

Nil vs Empty

var s []int      // nil — len=0, cap=0, s == nil is true
s := []int{}     // empty — len=0, cap=0, s == nil is false
// both work with len(), cap(), append(), range
// json.Marshal: nil → "null", empty → "[]"

⚠️ Slices returned by sub-slicing share the same backing array. Mutating one affects the other.


Maps

m := map[string]int{     // map literal
    "a": 1,
    "b": 2,
}
m := make(map[string]int)       // empty map
m := make(map[string]int, 100)  // empty map with capacity hint

Operations

m["key"] = 42              // set
v := m["key"]              // get (returns zero value if missing)
v, ok := m["key"]          // comma-ok idiom — ok is false if missing
delete(m, "key")           // delete (no-op if key absent)
len(m)                     // number of entries

Iteration

for k, v := range m {
    fmt.Println(k, v)      // order is randomized each run!
}

Nil Maps

var m map[string]int       // nil map
_ = m["key"]               // safe — returns zero value
m["key"] = 1               // PANIC — can't write to nil map
  • Reference type — passing to a function shares the underlying data
  • Not thread-safe — concurrent reads/writes cause a fatal crash. Use sync.Map or a sync.RWMutex
  • Keys must be comparable (== supported): no slices, maps, or functions as keys

Structs

type User struct {
    ID        int
    Name      string
    Email     string    `json:"email"` // struct tag for JSON field name
    CreatedAt time.Time `json:"created_at,omitempty"` // omit if zero
}

Construction

u := User{ID: 1, Name: "Jon"}          // named fields (preferred)
u := User{1, "Jon", "", time.Time{}}   // positional (fragile, avoid)
p := &User{ID: 1}                      // pointer to struct
u := new(User)                         // *User, zero-valued

Access

u.Name                     // direct field access
p.Name                     // auto-dereferenced (same as (*p).Name)

Anonymous Structs (useful for tests, one-off data)

point := struct {
    X, Y int
}{X: 1, Y: 2}

Struct Comparison

// Structs are comparable if all fields are comparable
a := User{ID: 1, Name: "Jon"}
b := User{ID: 1, Name: "Jon"}
fmt.Println(a == b) // true

Embedding

Go uses composition instead of inheritance.

type Animal struct {
    Name string
}

func (a Animal) Speak() string {
    return a.Name + " speaks"
}

type Dog struct {
    Animal          // embedded — Dog "inherits" Animal's fields and methods
    Breed string
}

d := Dog{
    Animal: Animal{Name: "Rex"},
    Breed:  "Labrador",
}
fmt.Println(d.Name)    // "Rex" — promoted field
fmt.Println(d.Speak()) // "Rex speaks" — promoted method
  • Embedding is not inheritance — there is no polymorphism between Dog and Animal
  • Outer type can override promoted methods by defining its own
  • You can embed interfaces too (useful for partial implementation / decoration)

Methods & Receivers

// Value receiver — operates on a copy
func (u User) FullName() string {
    return u.Name
}

// Pointer receiver — can mutate the original
func (u *User) SetName(n string) {
    u.Name = n
}

Rules

Use pointer receiver when... Use value receiver when...
Method modifies the receiver Method only reads the receiver
Struct is large (avoids copy) Struct is small (e.g., Point{X,Y})
Consistency — if any method uses pointer, all should Type is immutable by design
// Go auto-dereferences — you can call pointer methods on values and vice versa
u := User{Name: "Jon"}
u.SetName("Jane")     // compiler takes &u automatically
  • Methods can only be defined on types in the same package
  • You cannot define methods on built-in types directly (use a named type)

Interfaces

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

// Interface composition
type ReadWriter interface {
    Reader
    Writer
}

Implicit Implementation

No implements keyword — if a type has the required methods, it satisfies the interface.

type MyReader struct{}

func (MyReader) Read(p []byte) (int, error) {
    return 0, io.EOF
}

var r Reader = MyReader{} // works — MyReader satisfies Reader

Empty Interface

var x any          // any == interface{} (alias since Go 1.18)
x = 42
x = "hello"
x = []int{1, 2}   // anything can be assigned

Interface Best Practices

  • Keep interfaces small (1-2 methods)
  • Define interfaces where they're consumed, not where they're implemented
  • "Accept interfaces, return structs"
  • io.Reader, io.Writer, fmt.Stringer, error — learn these well

Nil Interface Gotcha

var p *User = nil
var i any = p
fmt.Println(i == nil) // false! interface holds (*User, nil), not nil itself

Generics

func Map[T any, U any](s []T, f func(T) U) []U {
    result := make([]U, len(s))
    for i, v := range s {
        result[i] = f(v)
    }
    return result
}

// usage
doubled := Map([]int{1, 2, 3}, func(x int) int { return x * 2 })

Constraints

type Number interface {
    ~int | ~int32 | ~int64 | ~float32 | ~float64
    // ~ means underlying type (allows named types like `type Age int`)
}

func Sum[T Number](nums []T) T {
    var total T
    for _, n := range nums {
        total += n
    }
    return total
}

Built-in Constraints

any         // no constraint (== interface{})
comparable  // supports == and != (usable as map keys)

Generic Types

type Stack[T any] struct {
    items []T
}

func (s *Stack[T]) Push(v T) { s.items = append(s.items, v) }
func (s *Stack[T]) Pop() (T, bool) {
    if len(s.items) == 0 {
        var zero T
        return zero, false
    }
    v := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return v, true
}

Error Handling

The Pattern

result, err := doSomething()
if err != nil {
    return fmt.Errorf("doSomething failed: %w", err) // wrap with context
}

Creating Errors

// Simple
err := errors.New("something went wrong")

// Formatted
err := fmt.Errorf("user %d not found", id)

// Wrapped (preserves chain for inspection)
err := fmt.Errorf("query failed: %w", originalErr)

Custom Error Types

type NotFoundError struct {
    ID   int
    Type string
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s %d not found", e.Type, e.ID)
}

// Return it
return nil, &NotFoundError{ID: id, Type: "user"}

Inspecting Errors

// Is — checks if any error in the chain matches a target value
if errors.Is(err, os.ErrNotExist) {
    fmt.Println("file not found")
}

// As — checks if any error in the chain matches a target type
var nfe *NotFoundError
if errors.As(err, &nfe) {
    fmt.Println("missing:", nfe.Type, nfe.ID)
}

Sentinel Errors

var ErrNotFound = errors.New("not found")  // package-level, exported
var errInternal = errors.New("internal")    // package-level, unexported

Never compare errors with == unless they're sentinel values. Use errors.Is().


defer, panic, recover

defer

f, err := os.Open("file.txt")
if err != nil {
    return err
}
defer f.Close() // guaranteed to run when function returns

// Multiple defers execute in LIFO order (stack)
defer fmt.Println("first")
defer fmt.Println("second") // prints "second" then "first"

Deferred calls' arguments are evaluated immediately, but the call happens later:

x := 1
defer fmt.Println(x) // prints 1, not 2
x = 2

panic

panic("something went catastrophically wrong")
panic(fmt.Sprintf("unexpected type: %T", v))
  • Unwinds the call stack, running deferred functions
  • Crashes the program if not recovered
  • Don't use for normal error handling — only for truly unrecoverable situations

recover

func safeCall() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recovered:", r)
        }
    }()

    panic("oh no")
}
  • recover() only works inside a deferred function
  • Returns nil if no panic is in progress
  • Common in libraries and HTTP handlers to prevent one bad request from crashing the server

init Functions

package mypackage

func init() {
    // runs automatically when package is imported
    // before main() executes
    // used for: registering drivers, setting defaults, validation
}
  • Each file can have multiple init() functions
  • Execution order: package-level vars → init()main()
  • Avoid complex logic in init() — it makes testing and debugging harder

Concurrency

Goroutines

go doWork()                                       // fire and forget

go func() {                                       // anonymous goroutine
    fmt.Println("running concurrently")
}()
  • Goroutines are multiplexed onto OS threads by Go's runtime scheduler
  • They start with a ~8KB stack that grows dynamically
  • Spawning thousands (even millions) is normal

sync.WaitGroup (wait for goroutines to finish)

var wg sync.WaitGroup

for i := 0; i < 5; i++ {
    wg.Add(1)                 // increment before launching
    go func(id int) {
        defer wg.Done()       // decrement when done
        fmt.Println("worker", id)
    }(i)
}

wg.Wait()                     // block until counter is zero

sync.Mutex (protect shared data)

var mu sync.Mutex
var count int

func increment() {
    mu.Lock()
    defer mu.Unlock()
    count++
}

sync.RWMutex (multiple readers, single writer)

var mu sync.RWMutex

func read() int {
    mu.RLock()
    defer mu.RUnlock()
    return count
}

sync.Once (run exactly once, even across goroutines)

var once sync.Once
var instance *DB

func GetDB() *DB {
    once.Do(func() {
        instance = connectDB()
    })
    return instance
}

⚠️ Concurrency rules:

  • Don't communicate by sharing memory; share memory by communicating (channels)
  • If you share memory, protect it with a mutex
  • No automatic cancellation or cleanup for goroutines — you must handle it

Channels

Channels are typed conduits for passing data between goroutines.

ch := make(chan int)       // unbuffered — send blocks until receiver is ready
ch := make(chan int, 10)   // buffered — send blocks only when buffer is full

Send / Receive

ch <- 42        // send (blocks if unbuffered and no receiver)
v := <-ch       // receive (blocks until a value is sent)
v, ok := <-ch   // ok is false if channel is closed and empty

Close

close(ch)       // signal that no more values will be sent
// receiving from closed channel returns zero value immediately
// sending to closed channel panics!

Range Over Channel

for v := range ch {        // loops until channel is closed
    fmt.Println(v)
}

Directional Channels (used in function signatures)

func produce(ch chan<- int) { ch <- 1 }  // send-only
func consume(ch <-chan int) { <-ch }     // receive-only

Select (multiplexing channels)

select {
case v := <-ch1:
    fmt.Println("from ch1:", v)
case ch2 <- 42:
    fmt.Println("sent to ch2")
case <-time.After(1 * time.Second):
    fmt.Println("timeout")
default:
    fmt.Println("no channel ready") // non-blocking if default present
}

Common Patterns

// Done channel (signaling completion)
done := make(chan struct{})
go func() {
    defer close(done)
    doWork()
}()
<-done // wait for completion

// Fan-out: launch multiple goroutines reading from one channel
// Fan-in: merge multiple channels into one
// Pipeline: chain of stages connected by channels

Context

Used for cancellation, deadlines, and request-scoped values across API boundaries.

import "context"

// Create contexts
ctx := context.Background()                     // root context (top-level)
ctx := context.TODO()                           // placeholder when unsure

ctx, cancel := context.WithCancel(parent)       // manual cancellation
ctx, cancel := context.WithTimeout(parent, 5*time.Second) // auto-cancel after timeout
ctx, cancel := context.WithDeadline(parent, deadline)     // auto-cancel at deadline

defer cancel() // ALWAYS call cancel to release resources

Checking for Cancellation

select {
case <-ctx.Done():
    fmt.Println("cancelled:", ctx.Err()) // context.Canceled or DeadlineExceeded
    return
case result := <-ch:
    fmt.Println(result)
}

Passing Context

// Always pass as the first parameter, named ctx
func FetchData(ctx context.Context, url string) ([]byte, error) {
    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    // ...
}

Never store context in a struct. Pass it explicitly.


Packages & Modules

Initialize a Module

go mod init example.com/myapp    # creates go.mod
go mod tidy                       # add missing / remove unused dependencies
go get example.com/pkg@v1.2.3    # add a dependency
go get -u ./...                   # update all dependencies

Import

import "fmt"                     // standard library
import "example.com/myapp/util"  // local package
import m "math"                  // aliased import (m.Sqrt)
import . "math"                  // dot import — use Sqrt directly (avoid)
import _ "image/png"             // side-effect import (registers PNG decoder)

Project Layout (common convention)

myapp/
├── go.mod
├── go.sum            # checksums (not a lockfile — auto-generated)
├── main.go           # package main
├── internal/         # private packages (not importable outside module)
│   └── db/
├── pkg/              # public library packages (optional convention)
│   └── util/
└── cmd/              # multiple binaries
    ├── server/
    └── cli/
  • One module per go.mod = many packages
  • Package name = directory name (by convention)
  • internal/ enforced by the compiler — cannot be imported from outside

Visibility & Naming

func PublicFunc()    // exported — accessible from other packages
func privateFunc()   // unexported — package-private

type PublicType struct {
    ExportedField   int    // accessible externally
    unexportedField int    // only within this package
}

Naming Conventions

Thing Convention Example
Package short, lowercase, no underscores http, strconv
Interface (1 method) method name + "er" Reader, Stringer
Acronyms all caps URL, HTTP, ID
Getters no Get prefix Name() not GetName()
Local vars short i, n, err, ctx

Memory & Zero Values

Every type has a zero value — allocated memory is always initialized.

Type Zero Value
int 0
float64 0.0
bool false
string ""
pointer nil
slice nil
map nil
channel nil
interface nil
struct all fields zeroed
func nil

Allocation

p := new(User)              // allocates zeroed User, returns *User
s := make([]int, 10)        // make is for slices, maps, channels only
m := make(map[string]int)
ch := make(chan int, 5)

Stack vs heap: Go's escape analysis decides. If a value outlives the function (e.g., returned pointer), it's heap-allocated. You rarely need to think about this.

Nil Safety

var s []int
len(s)          // 0 (safe)
append(s, 1)    // works (safe)
for range s {}  // works (safe)
s[0]            // PANIC — index out of range

var m map[string]int
m["key"]        // returns 0 (safe read)
m["key"] = 1    // PANIC — assignment to nil map

Testing

Basic Test

// file: math_test.go (must end in _test.go)
package math

import "testing"

func TestAdd(t *testing.T) {         // must start with Test
    got := Add(2, 3)
    want := 5
    if got != want {
        t.Errorf("Add(2,3) = %d, want %d", got, want)
    }
}

Table-Driven Tests (idiomatic)

func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive", 1, 2, 3},
        {"zero", 0, 0, 0},
        {"negative", -1, -2, -3},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := Add(tt.a, tt.b); got != tt.expected {
                t.Errorf("got %d, want %d", got, tt.expected)
            }
        })
    }
}

Benchmarks

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(1, 2)
    }
}
// run: go test -bench=.

Running Tests

go test ./...              # all packages
go test -v ./...           # verbose
go test -run TestAdd       # specific test
go test -cover             # coverage
go test -race              # race detector (essential for concurrent code)

Common Standard Library

// Formatting
fmt.Println("hello")                   // print with newline
fmt.Printf("name: %s, age: %d\n", n, a)  // formatted
fmt.Sprintf("id-%d", 42)              // returns string
fmt.Fprintf(w, "hello")               // write to io.Writer

// I/O
io.Copy(dst, src)                     // copy reader to writer
io.ReadAll(r)                         // read all bytes (Go 1.16+)
os.ReadFile("path")                   // read file to []byte (Go 1.16+)
os.WriteFile("path", data, 0644)      // write []byte to file

// Time
time.Now()                             // current time
time.Since(start)                      // duration since start
time.Sleep(100 * time.Millisecond)
t.Format("2006-01-02 15:04:05")       // Go uses this specific reference time!
time.Parse("2006-01-02", "2025-02-01")

// JSON
json.Marshal(v)                        // struct → []byte
json.Unmarshal(data, &v)               // []byte → struct
json.NewEncoder(w).Encode(v)           // stream to writer
json.NewDecoder(r).Decode(&v)          // stream from reader

// HTTP
http.Get("https://example.com")
http.ListenAndServe(":8080", handler)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "hello")
})

// Sorting
sort.Ints(s)                           // sort []int in place
sort.Strings(s)                        // sort []string in place
slices.Sort(s)                         // Go 1.21+ generic sort
slices.SortFunc(s, func(a, b int) int { return a - b })

// Logging
log.Println("info message")
log.Fatalf("fatal: %v", err)           // logs and calls os.Exit(1)
slog.Info("msg", "key", "value")       // structured logging (Go 1.21+)

Printf Verbs Cheatsheet

Verb Description Example Output
%v default format {1 Jon}
%+v with field names {ID:1 Name:Jon}
%#v Go syntax User{ID:1}
%T type main.User
%d decimal integer 42
%x hex 2a
%f float 3.140000
%.2f float (2 decimal) 3.14
%s string hello
%q quoted string "hello"
%p pointer 0xc0000b4008
%w wrap error (Errorf only)

Example Applications

CLI Tool: Word Counter

A simple CLI that counts words, lines, and characters from stdin or files.

// main.go
package main

import (
    "bufio"
    "fmt"
    "os"
    "strings"
)

func count(scanner *bufio.Scanner) (lines, words, chars int) {
    for scanner.Scan() {
        line := scanner.Text()
        lines++
        words += len(strings.Fields(line))
        chars += len(line) + 1 // +1 for newline
    }
    return
}

func main() {
    var scanner *bufio.Scanner

    if len(os.Args) > 1 {
        // read from file
        f, err := os.Open(os.Args[1])
        if err != nil {
            fmt.Fprintf(os.Stderr, "error: %v\n", err)
            os.Exit(1)
        }
        defer f.Close()
        scanner = bufio.NewScanner(f)
    } else {
        // read from stdin
        scanner = bufio.NewScanner(os.Stdin)
    }

    lines, words, chars := count(scanner)
    fmt.Printf("%8d %8d %8d\n", lines, words, chars)
}
# build and run
go build -o wc .
echo "hello world" | ./wc          #        1        2       12
./wc main.go                       # counts lines/words/chars in main.go

HTTP Server: JSON API

A minimal REST API with health check, JSON responses, and graceful shutdown.

// main.go
package main

import (
    "context"
    "encoding/json"
    "log"
    "net/http"
    "os"
    "os/signal"
    "time"
)

type StatusResponse struct {
    Status    string `json:"status"`
    Timestamp string `json:"timestamp"`
}

type Message struct {
    ID   int    `json:"id"`
    Text string `json:"text"`
}

// respondJSON writes a JSON response with the given status code
func respondJSON(w http.ResponseWriter, status int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

func main() {
    mux := http.NewServeMux()

    // health check
    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        respondJSON(w, http.StatusOK, StatusResponse{
            Status:    "ok",
            Timestamp: time.Now().UTC().Format(time.RFC3339),
        })
    })

    // list messages
    messages := []Message{
        {ID: 1, Text: "Hello, Go!"},
        {ID: 2, Text: "Concurrency is not parallelism."},
    }

    mux.HandleFunc("GET /messages", func(w http.ResponseWriter, r *http.Request) {
        respondJSON(w, http.StatusOK, messages)
    })

    // create message
    mux.HandleFunc("POST /messages", func(w http.ResponseWriter, r *http.Request) {
        var msg Message
        if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
            respondJSON(w, http.StatusBadRequest, map[string]string{
                "error": "invalid JSON",
            })
            return
        }
        msg.ID = len(messages) + 1
        messages = append(messages, msg)
        respondJSON(w, http.StatusCreated, msg)
    })

    // configure server
    srv := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    // start server in a goroutine
    go func() {
        log.Printf("listening on %s", srv.Addr)
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalf("server error: %v", err)
        }
    }()

    // graceful shutdown: wait for interrupt signal
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, os.Interrupt)
    <-quit // block until SIGINT

    log.Println("shutting down...")
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        log.Fatalf("shutdown error: %v", err)
    }
    log.Println("server stopped")
}
# build and run
go build -o api-server .
./api-server

# test endpoints (in another terminal)
curl http://localhost:8080/health
curl http://localhost:8080/messages
curl -X POST http://localhost:8080/messages \
  -H "Content-Type: application/json" \
  -d '{"text": "A new message"}'

Note: The "GET /messages" pattern syntax requires Go 1.22+. For older versions, use mux.HandleFunc("/messages", handler) and check r.Method manually.


Concurrent Pipeline: File Processor

Demonstrates goroutines, channels, WaitGroup, and the pipeline pattern.

// main.go
package main

import (
    "crypto/sha256"
    "fmt"
    "os"
    "path/filepath"
    "sync"
)

// FileHash holds a file path and its SHA-256 checksum
type FileHash struct {
    Path string
    Hash string
    Err  error
}

// walk sends file paths on a channel (producer)
func walk(root string) <-chan string {
    paths := make(chan string)
    go func() {
        defer close(paths) // close signals no more files
        filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
            if err != nil || info.IsDir() {
                return nil
            }
            paths <- path
            return nil
        })
    }()
    return paths
}

// hash reads files and computes checksums (worker)
func hash(paths <-chan string, results chan<- FileHash) {
    for path := range paths {
        data, err := os.ReadFile(path)
        if err != nil {
            results <- FileHash{Path: path, Err: err}
            continue
        }
        sum := sha256.Sum256(data)
        results <- FileHash{Path: path, Hash: fmt.Sprintf("%x", sum)}
    }
}

func main() {
    root := "."
    if len(os.Args) > 1 {
        root = os.Args[1]
    }

    // stage 1: walk directory tree
    paths := walk(root)

    // stage 2: fan-out to N workers
    results := make(chan FileHash)
    var wg sync.WaitGroup
    numWorkers := 4

    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            hash(paths, results)
        }()
    }

    // close results channel when all workers are done
    go func() {
        wg.Wait()
        close(results)
    }()

    // stage 3: collect results
    for r := range results {
        if r.Err != nil {
            fmt.Fprintf(os.Stderr, "error: %s: %v\n", r.Path, r.Err)
            continue
        }
        fmt.Printf("%s  %s\n", r.Hash[:16], r.Path)
    }
}
go build -o hasher .
./hasher .              # hash all files in current directory
./hasher /path/to/dir   # hash all files in a specific directory

Common Gotchas

Gotcha Explanation
nil interface ≠ nil value An interface holding a nil pointer is not nil itself
Shadowing with := Inner scope := creates a new variable, doesn't reassign outer
Map iteration order Randomized intentionally — don't depend on it
Slice sharing Sub-slices share the backing array; use copy or full slice expression
Nil map write panics Must make() a map before writing
JSON tags are case-sensitive json:"name" must match exactly
Loop var capture (pre-1.22) Goroutines in loops capture the variable, not the value
Goroutine leaks Goroutines run until they return — forgotten ones leak memory
defer in loops Defers don't run until the function exits, not the loop iteration
String indexing returns bytes "hello"[0] is a byte, not a rune

Idiomatic Go Rules

  1. Prefer clarity over cleverness — readable code wins
  2. Handle errors immediately — don't defer error checking
  3. Small interfaces — 1-2 methods; accept interfaces, return structs
  4. Composition over inheritance — embed, don't extend
  5. One obvious way to do things — resist abstraction for its own sake
  6. Don't stutterhttp.Server not http.HTTPServer
  7. Use gofmt — non-negotiable; format your code
  8. Document exported symbols — godoc comments start with the name
  9. Make the zero value usefulsync.Mutex{} is ready to use
  10. Don't over-channel — a mutex is fine when shared state is simpler

TL;DR

Go is:

  • Simple — small language spec, few keywords
  • Explicit — no hidden magic, no implicit conversions
  • Fast — compiled, statically typed, low-overhead concurrency
  • Predictable — one way to format, one way to handle errors

It rewards discipline and punishes cleverness.


Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment