← Back to Go

Go — Quick Summary

Quick revision: every topic with full mental models for Go — fundamentals, types, concurrency, runtime internals, stdlib, and production.


This is a quick revision doc covering all 34 topics in the Go collection. Each entry gives a full mental model on its own — read this as a standalone pass before an interview. Open the linked notes only if you want even more depth or extra code.

Fundamentals

What is Go & Why

Go (golang) is a language built at Google in 2009 by engineers fed up with slow builds and overly complicated code in large projects. The pitch in simple language: a language as fast as C, but as readable as Python. Four words come up constantly when people describe it. Compiled — our code becomes a machine-readable binary before running, so there’s no interpreter sitting between us and the CPU at runtime, which is why Go programs start instantly and run fast. Statically typed — every variable’s type is known at compile time, so trying to shove a string into an int gets caught by the compiler before the program ever runs, killing a whole class of bugs early. Garbage collected — we don’t manually free memory like in C/C++; a background collector cleans up what we no longer use, giving us memory safety without a heavy runtime like Java’s. Built for concurrency — doing many things at once is baked into the language via goroutines (think super-cheap threads we spin up with one keyword) and channels; this is Go’s killer feature. On top of that, Go is simple by design: very few features, no classes or inheritance, no exceptions, generics only added later — less to learn, fewer ways to write confusing code.

Why people actually love it in backend work: fast compile times (huge projects build in seconds), and go build produces a single static binary with everything inside — no “install 40 dependencies on the server first,” we just copy one file and run it. That’s why Go and Docker are best friends: tiny images, nothing to install. It also ships a great standard library (HTTP server, JSON, crypto all in the box) and gofmt formats everyone’s code identically, ending tabs-vs-spaces arguments. Much of the modern internet’s infrastructure is written in Go: Docker, Kubernetes, Terraform, Prometheus, CockroachDB, etcd.

When not to reach for Go: heavy data science/ML (Python’s NumPy/PyTorch ecosystem wins), rich GUI desktop apps (Go’s UI story is weak), extreme low-level/real-time control where the GC gets in the way (use C/C++/Rust), and quick one-off scripts (Python/Bash is faster to write).

package main
import "fmt"
func main() { fmt.Println("Hello, Go!") } // run with: go run main.go

Variables, Constants & iota

A variable is a named box holding a value we can change; a constant is the same idea but locked forever once set. Go gives us three ways to declare variables. The explicit form is var name type = value, e.g. var age int = 28. Because Go infers types from values, we can drop the type: var city = "Mumbai". And if we give no value at all, Go assigns the zero valuevar count int is 0. Inside functions there’s a shortcut, the short declaration operator :=, which declares and assigns in one step with inferred type: score := 100. The catch worth remembering: := only works inside functions; at package level (outside any function) we must use var. Once a variable exists, we reassign it with plain = (no colon).

Constants use const and must be known at compile time — we can’t set one from a function call or user input. They can be grouped in a const ( ... ) block for tidiness. A subtle, interview-worthy detail is typed vs untyped constants. An untyped constant has no fixed type until it’s used, making it a chameleon that adapts: const untyped = 5 can become an int or a float64 depending on context, so var f float64 = untyped is fine. A typed constant locks itself down: const typed int = 5 won’t auto-convert, so var g float64 = typed is an error.

Go has no enum keyword. Instead we use iota, an auto-incrementing counter that lives inside a const block. In simple language, iota starts at 0 and goes up by 1 for each line — we write it once and every line below keeps counting:

const (
    Sunday  = iota // 0
    Monday         // 1
    Tuesday        // 2
)

It even works with expressions. The classic trick defines byte sizes by bit-shifting, using the blank identifier _ to skip 0:

const (
    _  = iota
    KB = 1 << (10 * iota) // 1024
    MB                    // 1 << 20
    GB                    // 1 << 30
)

Data Types & Zero Values

A data type tells Go what kind of value a variable holds. Because Go is statically typed, every value has a type the compiler checks. The basic types: int (a platform-sized integer, 64-bit on modern systems — when in doubt, just use int), int64 (explicitly 64-bit), float64 (the default float; prefer it over float32 unless memory matters), string (text), and bool (true/false). Two more are aliases that exist to clarify intent: byte is just a nicer name for uint8 (one raw 8-bit chunk), and rune is a nicer name for int32 (one full Unicode character, which may span several bytes).

That byte-vs-rune distinction is a classic string gotcha. Go strings are UTF-8 encoded, so a character like é or an emoji takes multiple bytes but is a single rune. len(s) counts bytes, not characterslen("héllo") is 6, while len([]rune("héllo")) is 5. Ranging over a string with for i, r := range s yields runes, not bytes.

The big interview favorite is the zero value concept. In Go, every variable is automatically given a sensible default if we don’t initialize it — there’s no such thing as an uninitialized variable holding garbage memory. Go refuses to hand us a box with random junk: an uninitialized int is 0 and is already ready to be added to, a bool is already false. This kills the C problem (random garbage) and the Java problem (null blowing up). One thing to remember: a string’s zero value is the empty string "", not nil. Only the reference-y types (pointer, slice, map, channel, func, interface) are nil.

Type
Zero Value
int, int64, byte, rune
0
float64
0.0
bool
false
string
"" (empty string)
pointer, slice, map, channel, func, interface
nil

Functions

A function is a named block of code that takes inputs, does work, and returns a result. We declare it with func, list parameters with their types, and put the return type after the parentheses: func add(a int, b int) int. If consecutive params share a type, we write it once: func multiply(a, b int) int.

The first Go-specific twist is multiple return values — a function can return more than one thing, and this is everywhere. func divmod(a, b int) (int, int) returns both quotient and remainder. The most famous use is returning a result and an error together, which is the idiomatic Go pattern: value, err := strconv.Atoi("42"), then if err != nil { ... }. Think of value, err := as Go’s polite way of saying “here’s your answer, and here’s whether it worked.”

We can also give returns names. With named return values, a bare return automatically sends back whatever those named variables currently hold — they’re pre-declared at their zero values. It’s handy for short functions, but overusing it in long functions hurts readability.

Variadic parameters let a function accept any number of arguments of one type, marked with ... before the type. In simple language ...int means “zero or more ints.” Inside the function the parameter behaves like a slice, so we can range over it. Calling with zero args is fine, and we can spread an existing slice with nums.... fmt.Println is itself variadic.

func sum(nums ...int) int {
    total := 0
    for _, n := range nums { total += n }
    return total
}
sum(1, 2, 3)       // 6
sum(nums...)       // spread a slice

Functions are first-class values — we can store them in variables, pass them as arguments, and return them from other functions. That enables higher-order patterns like an apply(nums, op func(int) int) helper. Building on this, a closure is a function that remembers variables from the scope where it was created, even after that scope finished — think of it as a backpack the inner function carries. A counter() that returns func() int keeps its own private count alive across calls (1, 2, 3…), and each call to counter() gets a fresh, independent count. Closures are great for generators, callbacks, and keeping a little private state.

Packages & Go Modules

A package is a folder of Go files that work together; a module is a versioned collection of packages — basically one project or library. These two concepts are how Go organizes and shares code. Every Go file starts by declaring its package, e.g. package main. The special main package is where executable programs live and it must contain func main(); any other name (like utils or auth) is a regular library package meant to be imported. All files in one folder must share the same package name.

Go’s access control is a guaranteed interview question because it has no public/private keywords — the capitalization is the rule. If a name starts with a capital letter it’s exported (public, usable from other packages); lowercase means unexported (private to its own package). So mathx.Add(...) works from outside, but mathx.subtract(...) won’t even compile. We pull in other packages with import, then use the package’s last name as the prefix: fmt.Println, strings.ToUpper, auth.Login.

A module is the unit Go uses to track dependencies and versions, created with go mod init github.com/manish/myapp — that path is usually the repo URL. This creates go.mod, the heart of the project, listing the module name, the Go version, and every dependency with its version (think package.json). Alongside it, go.sum is a lockfile of cryptographic checksums recording the exact hash of each dependency so nobody can swap in tampered code (think package-lock.json). We commit both to git but never hand-edit go.sum.

To add a dependency we run go get github.com/gorilla/mux, which downloads it and updates both files automatically. Before committing, go mod tidy removes unused deps and adds any missing ones to keep go.mod honest. For building and running, go run main.go compiles and runs in one step leaving no file behind (great for dev), while go build produces the actual binary we ship and we run it ourselves with ./myapp.

Types & Data Structures

Arrays vs Slices

This is THE classic Go data-structure interview topic. In simple language, an array has its size baked into its type — [3]int and [4]int are literally different types, and the length is fixed forever. An array is also a value type: when we assign it or pass it to a function, Go copies every single element, so the copy is completely independent of the original. That copy-by-value behaviour surprises people, and it’s exactly why we almost never use arrays directly.

A slice is what we reach for 99% of the time. It’s a flexible, growable view over a backing array, written []int with no size in the brackets. Under the hood a slice is a tiny three-field header: a ptr to the backing array, a len (how many elements we can currently see), and a cap (how many fit before we run out of room).

slice header
ptr  →
len = 2
cap = 4
backing array (cap = 4)
10
20
·
·
solid = visible (len), dashed = spare (cap)

Slicing like nums[1:3] does NOT copy — the new slice points into the same backing array, like a window onto the same data. When we append and there’s spare capacity, Go writes into the existing array (fast); when capacity runs out, Go allocates a new bigger array (roughly doubling for small slices), copies everything over, and returns a slice pointing at the new array. That’s why we always write s = append(s, x). The big gotcha: because slices can share a backing array, appending into one slice’s spare capacity can silently overwrite another. To get a safe independent copy, use a full slice expression base[0:2:2] (caps capacity) or copy into a fresh slice.

base := []int{1, 2, 3, 4, 5}
a := base[0:2]      // {1,2} but cap is 5
a = append(a, 99)   // clobbers base[2]!
// base is now [1 2 99 4 5]

Maps

A map is Go’s built-in key-value store — a dictionary/hash table where we look things up by key instead of index. We create one with make(map[string]int) or a literal. The first trap: reading a missing key does NOT error, it returns the zero value of the value type. So ages["nobody"] gives 0, but we can’t tell whether we stored 0 or the key is absent. That’s why Go gives us the comma-ok idiom — a second boolean return that says whether the key actually exists:

v, ok := ages["nobody"]  // 0, false → missing
v2, ok2 := ages["manish"] // 27, true → present

We delete with the built-in delete(m, key), which is a safe no-op if the key isn’t there. Maps are reference-like: passing a map to a function does not copy it, it shares the same underlying hash table, so the function can mutate the caller’s map. This is the opposite of arrays, which copy by value.

A favourite interview point: iteration order is deliberately randomized. Run the same range loop twice and keys may come out differently — Go does this on purpose so we never accidentally depend on order. If we need sorted output, we collect keys into a slice and sort them. Another big one: maps are not safe for concurrent writes. Two goroutines writing the same map at once triggers a hard “concurrent map writes” panic that crashes the program. For concurrent access we guard with a sync.Mutex, or use sync.Map for read-heavy patterns. Finally, a nil map (declared but never maked) is read-safe — reads just return zero values — but writing to a nil map panics (“assignment to entry in nil map”). So we always make a map before writing.

Structs & Embedding

A struct groups related fields under one type — think of it like a record or a database row, a bundle of named values that belong together. We can create them with named-field literals (User{Name: "Manish", Age: 27} — preferred, because it survives field reordering), positional literals (fragile, order matters), or partial literals where omitted fields take their zero value. Go also supports anonymous structs — a one-off shape declared inline, handy for quick config or test data.

Struct tags are little metadata strings attached to fields. The encoding/json package reads them to decide JSON key names; without a tag it uses the field name as-is. The common omitempty option means “leave this key out of the JSON if the value is zero/empty,” which shows up everywhere in API code.

type Product struct {
    ID    int     `json:"id"`
    Price float64 `json:"price,omitempty"` // dropped when 0
}

Now the big concept: Go has no classes and no inheritance. Instead it gives us embedding — we drop one struct inside another without a field name, and the outer struct gets the inner one’s fields and methods for free. In simple language, embedding composes abilities rather than subclassing them. Because Animal is embedded in Dog, the Dog automatically promotes Animal’s fields and methods, so we can call d.Name and d.Speak() directly even though we never redeclared them.

type Dog struct {
    Animal      // embedded — NO field name
    Breed string
}

If Dog defines its own Speak(), that shadows the embedded one and takes priority — which is how we “override” behaviour. This whole pattern is Go’s composition-over-inheritance: the outer type borrows the inner type’s capabilities, and same-named outer methods win.

Methods & Receivers

A method is just a function with a special receiver argument that attaches it to a type — the (r Rect) bit between func and the name says “this function belongs to Rect.” The top interview question here is value vs pointer receiver. A value receiver (r Rect) gets a copy of the value, so any mutations inside are lost. A pointer receiver (r *Rect) gets the address of the original, so mutations stick. In simple language: value receiver = work on a photocopy, pointer receiver = work on the real thing.

value receiver (r Rect)
gets a COPY
mutations → lost
good for: read-only,
small structs
pointer receiver (r *Rect)
gets the ADDRESS
mutations → stick
good for: mutation,
big structs

Two simple rules cover almost everything: use a pointer receiver if the method mutates the receiver, and use a pointer receiver if the struct is large (to avoid an expensive copy). One consistency rule: if any method on a type uses a pointer receiver, make them all pointer receivers — mixing causes subtle interface issues. Small, read-only types like Point or time.Time are fine with value receivers. Finally, the addressability gotcha: to call a pointer-receiver method Go must take the value’s address, which only works if the value is addressable (a variable, slice element, or struct field). Go kindly auto-inserts the & when we call c.Inc() on a variable, but a temporary literal like Counter{}.Inc() has no fixed address and won’t compile.

Interfaces

An interface is a set of method signatures — it describes behaviour, not data. Anything that has those methods can be used wherever the interface is expected. The interview favourite is implicit satisfaction: unlike Java or C#, Go has no implements keyword. A type satisfies an interface automatically just by having the right methods — we never declare the relationship. This is duck typing: if it walks and quacks like a duck, Go treats it as a duck.

Go favours small interfaces, often just one method — io.Reader and io.Writer each have a single method, and huge chunks of the standard library plug together through them. The smaller the interface, the more types satisfy it; “the bigger the interface, the weaker the abstraction.” The empty interface interface{} (or its Go 1.18+ alias any) has zero methods, so it’s satisfied by everything and can hold any value — but it throws away type safety, so we use it sparingly.

Under the hood, an interface value is a two-part box storing both the concrete type and the value:

interface value (Speaker)
type
Dog
value
Dog{}
an interface is nil only when BOTH halves are nil

This design causes the famous nil-interface gotcha: an interface is nil only when both its type half and value half are nil. If we stuff a nil pointer into an interface, the interface still holds a type (*MyErr), so it is NOT nil even though the value inside is. Returning a typed nil pointer as an error makes err == nil evaluate to false, which trips up everyone — so we always return a literal nil for the no-error case, never a typed nil pointer.

Pointers

A pointer is a value that holds the memory address of another value — instead of “the data,” it’s “where the data lives.” Two operators do all the work: &x gives the address of x (a pointer to it), and *p dereferences — follows the pointer to get the value it points at. Writing *p = 20 writes through the pointer and changes the original variable.

x := 10
p := &x          // *int pointing at x
*p = 20          // x is now 20

If we come from C, drop one habit: Go has no pointer arithmetic. We cannot do p++ to walk memory — we can only point at something, read it, or write it. This is for safety (pointer math is a huge source of bugs and security holes) and because Go’s garbage collector needs to know exactly what’s a pointer. This makes Go pointers far safer than C pointers.

We pass pointers for two real reasons. First, mutation: Go passes everything by value (a copy), so if a function should change the caller’s data it needs a pointer (func doublePtr(n *int) { *n *= 2 }). Second, avoiding big copies: passing a large struct by value copies every field, whereas passing a pointer copies just one address — cheaper.

To allocate and get a pointer there are two ways. new(T) allocates a zeroed T and returns *T; &T{...} makes a struct literal (optionally with field values) and returns its address. In practice we almost always use &T{...} because it lets us set fields immediately; new is rare, mainly for a zeroed pointer to a basic type. Note Go auto-dereferences, so we write a.Name, not (*a).Name. Finally, a pointer that points at nothing is nil, and reading a field through a nil pointer triggers the classic “nil pointer dereference” panic — so always guard with if p != nil when a pointer could be nil.

Type Assertions & Switches

Once a value is sitting inside an interface (like any), its concrete type is hidden. A type assertion pulls that concrete type back out — in simple language, “I believe this interface is actually holding a string, give me the string.” The syntax is x.(T): the value, a dot, and the type in parentheses.

The catch: the single-value form s := x.(string) panics if we’re wrong about the type. So most of the time we use the safe comma-ok form, the same pattern we saw with maps — it returns the zero value and false instead of crashing:

var x any = "hello"
s, ok := x.(int)    // 0, false — no panic
n, ok := x.(string) // "hello", true

The single-value (panic) form is fine only when we’re 100% certain of the type, or when a wrong type genuinely should be a crashing programmer error. The rule of thumb is to prefer v, ok := x.(T).

When a value could be one of several types, chaining assertions is ugly, so we use a type switch with the special syntax switch v := x.(type) (the x.(type) form only works inside a switch). The clever part is that inside each case, the variable v is automatically narrowed to that case’s concrete type — in the int case v is an int, in the string case it’s a string — so we can call type-specific operations like v*2 or len(v) without another assertion.

switch v := x.(type) {
case int:    return fmt.Sprintf("int: %d", v*2)
case string: return fmt.Sprintf("len %d", len(v))
default:     return "unknown type"
}

This all ties back to interfaces: because an interface secretly carries its (type, value) pair, assertions and type switches are just us inspecting that hidden type half and recovering the value in its real form — the standard way to handle an any or recover the concrete type behind a small interface like error.

Error Handling

The error Interface

In Go an error is just a value — not a special crash, not a thing we “throw,” but a regular value that a function returns alongside its result. We check it, and we decide what to do. This is the famous “errors as values” philosophy, and it’s one of the most distinctly Go things there is. In simple language: instead of “try something and catch the explosion later,” Go says “do something, and tell me right here if it went wrong.”

Why no exceptions like Java or Python? Go’s authors hated that the error path becomes invisible — you can’t tell by reading a function whether it might blow up somewhere far away. They wanted failures to be right there in the code, impossible to ignore. So normal failures (file not found, network timeout, bad input) are not exceptions in Go; they’re returned values you must deal with on the spot.

The error type itself is tiny — just an interface with one method:

type error interface {
	Error() string
}

Anything with an Error() string method is an error. No magic. By convention, fallible functions return the error as their last return value, and we check it immediately with the idiomatic if err != nil guard — like a bouncer at every door: if the error is there, you don’t get to touch the result. We usually don’t handle the error where it happens; we hand it upward and let the top-level caller (like an HTTP handler) decide what to show.

Two everyday tools create errors: errors.New("insufficient funds") for a static message, and fmt.Errorf("user %d not found", id) when we want to drop variables in. A nil error means “all good.” Sometimes we need to check for a specific known error — a sentinel — like io.EOF signalling end-of-file, which we compare by identity (in modern Go, via errors.Is).

Yes, the if err != nil { return err } boilerplate is real and noisy. But it’s a deliberate trade-off: every failure path is visible top to bottom, the compiler nudges us to actually check, and the control flow stays dead simple — no hidden stack unwinding, no “where does this catch?” puzzles.

defer, panic & recover

These three keywords cover cleanup and the truly exceptional. defer schedules a function call to run when the surrounding function exits — no matter how it exits, normal return or panic. In simple language: “do this later, right before I leave, whatever happens.” We love it for cleanup: open something, immediately defer closing it, and never worry about forgetting even if the function returns from ten spots. The classic pairs are defer f.Close() after opening a file and defer mu.Unlock() after mu.Lock().

Two things to remember. First, deferred calls run in LIFO order — last deferred runs first, like a stack of plates. Second — the gotcha that trips up everyonearguments are evaluated immediately at the defer line, not when the call eventually runs. So defer fmt.Println(i) freezes i’s current value; if you want the latest value at exit time, wrap it in a closure: defer func() { fmt.Println(i) }().

defer stack — pushed top to bottom, popped bottom to top
defer "3"  ← pushed last, runs FIRST
defer "2"
defer "1"  ← pushed first, runs LAST
↓ function exits → pop 3, then 2, then 1

panic stops normal flow and unwinds the stack (running deferred functions on the way up); if nobody catches it, the program crashes with a stack trace. It’s the fire alarm — reserved for truly unrecoverable situations like broken invariants or a missing required env var, not for everyday “file not found” control flow. For normal failures, return an error.

recover stops a panic mid-unwind and lets the program keep running — but it only works inside a deferred function; anywhere else it does nothing. It’s the safety net you must hang before the fall. The classic use is a web server whose request handler shouldn’t kill the whole process: a deferred func calls recover(), logs it, returns a 500, and stays alive for other requests.

panic()
flow stops, stack starts unwinding
deferred funcs run
in LIFO order, on the way up
recover()
caught → program lives. not caught → crash

Error Wrapping (Is / As)

Error wrapping means putting one error inside another so we can add context (“while loading config: …”) without throwing away the original underneath. It landed in Go 1.13 and fixed a real pain. Before it, adding context meant fmt.Errorf("loading config failed: %s", err.Error()) — which flattened the original error into plain text. Once it’s just a string, we can never again ask “was this a file-not-found?” or “what was the original type?” — that information is gone forever.

The fix is the special %w verb (“wrap”) in fmt.Errorf. It adds our context message and keeps a live link to the original error underneath: return fmt.Errorf("loading config: %w", err). Think of it like nesting boxes — each layer adds a label on the outside, but the original item is still in there intact, and we can open the boxes one at a time. errors.Unwrap peels exactly one layer; we rarely call it directly, but it’s what powers Is and As.

To inspect the chain we use two functions. errors.Is(err, target) answers “is this specific sentinel value (like os.ErrNotExist or sql.ErrNoRows) anywhere in the chain?” — it walks the whole chain unwrapping as it goes, so it finds the target even buried five layers deep. This is why we use errors.Is instead of == now: plain == only checks the outer error and misses wrapped ones. errors.As(err, &target) answers “is there an error of this type in the chain? give it to me” — it searches for a matching type and fills in our variable (note: pass a pointer) so we can read its fields, like a *ValidationError’s .Field.

Rule of thumb: errors.Is compares against a specific value (sentinel); errors.As extracts a specific type (to read its fields).

The wrapped error chain — Is/As walk it from outside in
"loading config: ..."  (outer)
"open file: ..."  (middle)
os.ErrNotExist  ← errors.Is finds this here
errors.Unwrap peels one box at a time · Is/As keep peeling until match or end

Wrapping beats string concatenation because the original error survives (value and type), printing gives a readable trail ("loading config: open file: file does not exist"), and callers stay decoupled — a top-level handler can check errors.Is(err, sql.ErrNoRows) without caring how many layers wrapped it. One caution: %w makes the inner error part of your public API (callers can depend on it); if you don’t want that coupling, use %v to include the text without exposing the wrapped value.

Concurrency

Goroutines

A goroutine is just a function running concurrently with everything else, and we start one by sticking the word go in front of a call: go doSomething(). That line kicks the work off and immediately moves on — we don’t block, we don’t wait, the goroutine does its thing in the background. In simple language it’s a super-cheap thread we spin up with one keyword.

What makes goroutines special is cost. A normal OS thread grabs around 1MB of stack and the operating system has to schedule it, so spinning up thousands gets expensive fast. A goroutine starts with only about 2KB of stack that grows and shrinks as needed, so we can comfortably run tens of thousands of them. Think OS thread = renting a big apartment, goroutine = crashing on a friend’s couch. The other trick is they don’t map one-to-one onto OS threads — the Go runtime scheduler multiplexes many goroutines onto a small number of OS threads, acting as the smart middleman juggling everything.

Two gotchas bite everyone, and both are interview favorites. First, when main() returns the whole program exits and any still-running goroutines get killed instantly with no mercy. So go fmt.Println("hi") at the end of main usually prints nothing — the goroutine never gets to run. We have to synchronize using a sync.WaitGroup or a channel. Second is the classic loop-variable capture bug: before Go 1.22 the loop variable was shared across iterations, so for _, v := range ... { go func(){ fmt.Println(v) }() } would often print the last value three times because every closure captured the same v. The old fix was shadowing (v := v) or passing it as an argument. Go 1.22 changed loop semantics so each iteration gets a fresh variable and the naive version now works — but interviewers still love asking, so know both the bug and the fix.

Go runtime scheduler multiplexes goroutines onto OS threads
G1 G2 G3 G4 G5 ... thousands
↓ scheduler ↓
OS Thread 1 OS Thread 2

Channels

A channel is a typed pipe goroutines use to send and receive values — one puts something in, another takes it out. It embodies Go’s famous motto: “Don’t communicate by sharing memory; share memory by communicating.” In simple language, instead of several goroutines poking at one variable behind locks, we pass the data through a channel so whoever holds the value owns it — no locks, no races. We create one with make, send with ch <- x, and receive with <-ch. The arrow always points in the direction the data flows.

The part interviewers dig into is unbuffered vs buffered. An unbuffered channel (make(chan int)) has no storage — a send blocks until someone is ready to receive and a receive blocks until someone is ready to send. It’s a synchronous handshake where both sides meet at the same instant. A buffered channel (make(chan int, 3)) has a little waiting room: sends only block when the buffer is full, receives only block when it’s empty, so a sender can drop a few values and walk away.

When we’re done sending we close the channel. A for v := range ch loop keeps receiving until the channel is closed and fully drained, and the two-value receive v, ok := <-ch sets ok to false once it’s closed and empty. Three dangerous rules trip people up constantly: sending on a closed channel panics (so only the sender should ever close, never a receiver); receiving from a closed channel returns the zero value immediately with ok == false and never panics; and a nil channel blocks forever on both send and receive — occasionally useful to disable a select case, but usually a bug or deadlock.

Unbuffered — handshake
sender → ⟷ → receiver
both block until they meet
Buffered (cap 3)
sender [ _ _ _ ] receiver
sender only blocks when buffer is full

The select Statement

select lets a single goroutine wait on multiple channel operations at once. It blocks until one of them is ready, then runs that case. Think of it as a switch for channels — except instead of comparing values, each case is a channel send or receive, and it picks whichever is ready first. So select { case msg := <-ch1: ... case msg := <-ch2: ... } sits and waits, and the moment either channel has something, that case fires and the rest are ignored.

A key detail interviewers probe: if several cases are ready at once, Go picks one at random. This is deliberate — it stops any single channel from starving the others — so we can never rely on ordering or assume the first case wins. Add a default case and select stops blocking entirely: if nothing is ready right now it runs default immediately. That’s the classic “try to receive but don’t wait around” non-blocking pattern.

Two patterns to know cold. The timeout pattern pairs a real channel against time.After, which returns a channel that fires after a duration: case <-time.After(2*time.Second): ... wins if the real work didn’t deliver in time — a dead-simple way to avoid waiting forever. The done-channel pattern tells a goroutine to stop: a worker loops select-ing between its jobs channel and a done channel, and because a closed channel always returns immediately, closing done from outside makes the <-done case fire instantly and the worker returns cleanly. In real code we’d usually reach for context to play this role, but the mechanism is the same.

select waits on all channels, fires the first ready one
ch1 ch2 ch3
select first ready case runs

The sync Package

The sync package is the lock-based toolkit for when channels aren’t the right tool — sometimes we just need to guard a shared variable or wait for a batch of goroutines to finish. A sync.Mutex is a lock that only one goroutine can hold at a time; we Lock() before touching shared data and Unlock() after, and the idiomatic move is defer mu.Unlock() so we always release it even on a panic. Think of it as a single bathroom key — whoever holds it gets in, everyone else queues. A sync.RWMutex is smarter when reads vastly outnumber writes: many goroutines can hold the read lock together (RLock/RUnlock), but a write lock (Lock/Unlock) is exclusive — ideal for config that’s read constantly but updated rarely.

The canonical pattern that shows up in nearly every Go interview is sync.WaitGroup, which counts running goroutines and blocks until they’re all done. The recipe is Add, Done, Wait: call wg.Add(1) before launching each goroutine (not inside it), defer wg.Done() at the top of the goroutine, and wg.Wait() in the goroutine that needs everything finished.

sync.Once guarantees a function runs exactly one time no matter how many goroutines call once.Do(...) — perfect for lazy initialization like opening a connection or loading config. sync.Map is a ready-made concurrent map (a plain map panics on concurrent writes), but it’s specialized — it only shines when keys are written once and read many times, or when goroutines touch disjoint keys; otherwise a plain map guarded by a mutex is clearer and faster. For simple counters and flags, sync/atomic (e.g. atomic.AddInt64(&n, 1), atomic.LoadInt64(&n)) does lock-free updates that beat a mutex, though it can’t guard multiple fields together. The rule of thumb: use a mutex to protect shared state, use a channel to communicate and transfer ownership. If you’re sharing memory, lock it; if you’re communicating, channel it.

The context Package

context is the standard way to carry cancellation signals, deadlines, and request-scoped values down through a chain of function calls and goroutines. In simple language it’s a “stop” signal we hand to every function involved in a request — when the user closes the tab or a timeout hits, we flip the signal and everyone downstream knows to give up and clean up. Why it matters: an HTTP request might trigger a DB query that triggers another service call; if the client disconnects, all that work is wasted and those goroutines keep grinding. Context flows the cancellation down so they all stop, preventing leaked goroutines and wasted work.

Every context tree starts from a root — context.Background() — used at the top of main, in tests, and at request entry points. From there we derive children that add behavior: WithCancel gives us a manual cancel() function, WithTimeout auto-cancels after a duration, and WithDeadline auto-cancels at a specific wall-clock time. Always defer cancel() — even when the context finishes on its own, calling cancel releases resources, and forgetting it leaks. To listen, ctx.Done() returns a channel that closes on cancellation and ctx.Err() tells us why (context.Canceled or context.DeadlineExceeded). We typically select on <-ctx.Done() against our real work. In an HTTP handler, r.Context() already carries a context tied to the connection that cancels automatically if the client goes away — we just have to listen and bail out instead of finishing wasted work.

A few firm conventions interviewers check for: pass ctx as the first argument, always named ctx (func DoThing(ctx context.Context, arg string)); don’t store context in a struct — it’s per-request and short-lived while a struct usually outlives the request, so pass it explicitly; and don’t use context.Value for regular parameters — it’s only for request-scoped metadata that crosses API boundaries like a request ID or auth token. Values are untyped interface{}, so abusing them hides your function’s real inputs.

Concurrency Patterns

Once we know goroutines, channels, select, and sync, the next step is combining them into the patterns that show up in real backend code and interviews. A worker pool spins up a fixed number of goroutines that all pull from one shared jobs channel and push to a results channel — like a checkout line with N cashiers where whichever is free grabs the next customer. We control concurrency simply by choosing how many workers to start. The critical shutdown trick: close(jobs) so each worker’s range loop finishes, then close results in a separate goroutine once a WaitGroup confirms every worker is done — that lets the final range results terminate cleanly. Fan-out means several goroutines read from the same channel to spread work across cores (the worker pool is already fan-out); fan-in is the merge step that funnels several channels back into one, again closing the merged output only after a WaitGroup confirms all inputs are drained.

A pipeline is a chain of stages, each a goroutine connected by channels — the output of one stage is the input of the next, like an assembly line. Each stage takes a receive-only input channel and returns a receive-only output channel; stages run concurrently and closing flows downstream. Rate limiting uses a time.Ticker whose channel fires at a fixed interval — we read <-limiter.C before each action to throttle ourselves (e.g. a 200ms tick = 5 ops/sec). Done-channel cancellation stops a pipeline early: we pass a done channel into each stage and select on it alongside the real send, so closing done signals everyone to quit (in real code context standardizes this same idea). The recurring discipline across all of these: close channels from the sender side, and propagate cancellation downstream so nothing leaks.

jobs chan
worker 1 worker 2 worker 3
results chan
Fan-out
src
w1 w2
Fan-in
c1 c2
merged

Data Races & the Race Detector

A data race happens when two or more goroutines access the same memory at the same time, at least one is writing, and there’s no synchronization between them. All three conditions must be true — two goroutines only reading is fine, a single goroutine is fine, properly locked access is fine, but concurrent unsynchronized access with a write in the mix is a race. They’re the worst kind of bug because they’re nondeterministic: the program might work perfectly 999 times and corrupt data the 1000th, depending on exact timing, which CPU core ran what, and the mood of the scheduler. In simple language it’s like two people editing the same cell of a Google Doc at the same instant with no locking — whoever writes last wins, you can’t predict who, and the result is corrupted state, lost updates, or a crash that almost never reproduces while you’re watching.

The classic example is many goroutines doing counter++ with no lock. It looks atomic but it’s actually three steps — read, add one, write back — so two goroutines can read the same value, both add one, and both write it back, turning two increments into one. We lose updates and the final count is almost never what we expect.

Go’s headline tool, which interviewers love that the language ships built in, is the race detector: add -race and Go instruments your program to catch races at runtime — go run -race, go test -race ./..., go build -race. When it catches one it prints exactly which goroutines touched which memory and where, with full stack traces. It only finds races on code paths that actually execute, so run it against tests and real workloads; it’s slower and uses more memory, making it a dev/CI tool, not a production one. To fix a race you guard the shared variable with a sync.Mutex (or atomic.AddInt64 for a single counter), or you give one goroutine sole ownership of the state and have others send it work over a channel — share memory by communicating, so only one goroutine ever touches the data and there’s nothing left to race. The pre-Go-1.22 loop-variable capture is a genuine example: every goroutine read the same i while the loop kept writing it, and -race lights it up on old Go.

Runtime & Memory

Garbage Collection

In simple language: we keep allocating stuff in Go, and a little janitor runs in the background, figures out what’s trash, and frees it for us — we never call free() ourselves. The thing that makes Go’s collector special is what it optimizes for. Most GCs force a choice between throughput (fast total cleanup) and latency (short pauses), and Go firmly picked latency. The whole design goal is “don’t freeze the program for long” — pauses are usually sub-millisecond, which is exactly what you want for servers handling live requests where a 100ms freeze means angry users. The trade-off is that Go’s GC does a bit more total work and burns more CPU than a throughput-focused one; the authors decided predictable short pauses are worth it.

Three design choices define it. It’s concurrent (runs on its own goroutines at the same time as our code, barely stopping the world), non-generational (no young/old buckets like Java — it just scans everything reachable), and non-compacting (it never moves objects to defragment, so an address stays put, which is partly why holding Go pointers is safe).

The algorithm is tri-color mark-and-sweep. Everything starts white (suspect, not looked at). Roots — globals and goroutine stacks — turn grey (reachable, but its children still need checking). We pick a grey object, paint everything it points to grey, then paint it black (reachable and fully scanned). Repeat until no grey remains; whatever’s still white is unreachable garbage and gets swept.

WHITE
Not scanned yet. Suspect — might be garbage.
GREY
Reachable, but its children still need checking. The to-do list.
BLACK
Reachable AND fully scanned. Safe — keep it.
Start all WHITE → roots go GREY → scan grey, children turn GREY, scanned turns BLACK → leftover WHITE = garbage → sweep

The danger of scanning live is that our code could sneak a pointer from a black (done) object to a white (about-to-die) object mid-scan, and the collector would wrongly free it. The fix is write barriers — tiny compiler-injected code around pointer writes that whisper to the GC “note this new connection,” keeping the colors honest. There are two brief stop-the-world pauses (turn the barrier on, finish marking), but those are the sub-millisecond ones, not a full freeze. We tune with GOGC (default 100 = let the heap grow 100% past the live set before collecting; higher = less GC, more RAM) and GOMEMLIMIT (Go 1.19+, a soft cap that makes GC work harder as you approach it — the emergency brake that prevents container OOM kills).

Stack vs Heap & Escape Analysis

Every value lives in one of two places, and where it lives changes both speed and who cleans it up. In simple language: the stack is a fast scratchpad that gets wiped automatically when a function returns; the heap is a big shared storage room the garbage collector has to clean up later. Stack allocation is just bumping a pointer, everything vanishes instantly on return, no GC involved, and each goroutine gets its own. Heap is slower — the runtime finds space and the GC must later track and free it — but anything that must outlive its function has to go there. So we want values on the stack whenever possible.

STACK (per goroutine)
frame: main()
frame: doWork() — x, y, local vars
✓ fast (pointer bump)
✓ freed automatically on return
✗ small + can't outlive the function
HEAP (shared)
escaped object #1 ← pointer
escaped object #2 ← pointer
✗ slower to allocate
✗ GC must track + free it
✓ survives after the function returns

Who decides? Unlike C where we pick (malloc = heap), in Go the compiler decides at compile time via escape analysis. It asks one question per value: “does anything need this after the function returns?” If no, keep it on the cheap stack; if yes, it escapes to the heap. Common escape triggers: returning a pointer to a local variable, storing a concrete value in an interface (interface boxing), closures capturing a variable that outlives the parent, values too big for the stack, and sending a pointer to a channel. Classic example:

func newUser(name string) *User {
	u := User{Name: name}
	return &u  // address handed out → u escapes to the heap
}

In C that looks like a dangling-pointer bug; in Go the compiler sees the escape and quietly moves u to the heap so the pointer stays valid. We don’t have to guess — go build -gcflags="-m" prints the reasoning (“moved to heap: u” vs “x does not escape”), which is gold for performance work since fewer escapes means less GC pressure. Bonus: each goroutine starts with a tiny ~8KB stack that grows by reallocating and copying frames, which is why millions of goroutines are cheap.

The Scheduler (GMP)

The scheduler decides which goroutine runs on which OS thread and when — it’s what lets us launch a million goroutines without melting the laptop. In simple language: OS threads are expensive and few, goroutines are cheap and many, and the scheduler squeezes all those goroutines through the small number of threads efficiently. The model has three players. G — Goroutine, the lightweight task we make with go func(), with its own tiny stack; millions can exist. M — Machine, a real OS thread, the only thing that can actually execute code on a CPU; limited and pricey. P — Processor, a logical scheduling slot holding a local run queue of waiting goroutines, with GOMAXPROCS of them (default = CPU cores). The key rule: to run a G, an M must hold a P — no P, no running. Think of a P as a desk, an M as a worker, Gs as tasks on the desk; the number of desks is fixed at CPU count, so that’s how much truly runs in parallel.

P0 (desk)
M0 (thread) → running G3
local run queue: [G7] [G9] [G12]
P1 (desk)
M1 (thread) → running G5
local run queue: [ ] (empty → will STEAL)
GLOBAL run queue (shared overflow): [G20] [G21] ...
P1 is idle → it STEALS half of P0's queue → keeps every CPU busy

Each P’s local queue is lock-free and fast, but when one P empties while another is swamped, the idle P does work stealing — it grabs roughly half the goroutines off a busy P (or pulls from the shared global queue), so no CPU sits idle. The model really shines on blocking syscalls: when a goroutine blocks (say reading a file), its M is stuck waiting on the kernel, so the runtime does a handoff — it detaches the P from the blocked M and hands it to another (fresh or parked) M that keeps running the queued goroutines. That’s why thousands of goroutines doing blocking I/O don’t grind to a halt. Scheduling was originally purely cooperative (a goroutine only yielded at safe points like function calls or channel ops, so a tight loop could hog a P forever), but since Go 1.14 there’s asynchronous preemption — the runtime can interrupt a long-running goroutine via signal and force it to yield, which also lets the GC reliably pause goroutines. Goroutines are cheap because of tiny growable ~8KB stacks (vs ~1–2MB fixed per OS thread), user-space switching (no slow kernel context switch), and being multiplexed onto just GOMAXPROCS threads — creating an OS thread is like hiring an employee; creating a goroutine is like writing a sticky note.

Standard Library & Web

net/http

net/http is Go’s standard-library package for building HTTP servers and clients — no framework needed, it ships with the language. That’s a big part of why Go is loved for backend work: we can spin up a production-grade server in about three lines. The fastest path is http.HandleFunc("/path", fn) to register a handler, then http.ListenAndServe(":8080", nil) to start listening. Passing nil as the second argument means “use the default router” (a global ServeMux).

Every handler has the same shape: func(w http.ResponseWriter, r *http.Request). We write the response (body, status, headers) into w, and we read the method, URL, headers, and body from r. One gotcha that bites people: call w.WriteHeader(status) and set headers before writing the body — once you write the body, the status and headers are locked in.

Under the hood everything sits on one tiny interface: Handler, with a single method ServeHTTP(w, r). Anything that has that method is a handler. http.HandleFunc is just a convenience that wraps a plain function into an http.HandlerFunc so it satisfies the interface — so “a handler function” and “a Handler” are the same thing in different clothes.

http.ServeMux is the router (multiplexer) that matches a request path to a handler. The big news is Go 1.22: ServeMux gained method-and-path patterns. Before, it only matched plain prefixes, so we reached for gorilla/mux or chi. Now mux.HandleFunc("GET /users/{id}", ...) works in stdlib, and we read the variable with r.PathValue("id").

Middleware isn’t a special feature in Go — it’s just a function that takes a Handler and returns a new Handler that does extra work before/after calling the original. Since each returns a Handler, they chain like an onion: auth(logging(mux)).

On the client side, http.Get(url) fires a request and returns a response. The rule everyone forgets: always defer resp.Body.Close(), or you leak connections.

request flow
HTTP Request ServeMux matched Handler ServeHTTP(w, r)
the mux picks the handler by method + path, then calls its ServeHTTP

JSON Encoding/Decoding

encoding/json converts Go values to JSON and back. The jargon: encoding is marshaling (Go struct → JSON bytes) and decoding is unmarshaling (JSON bytes → Go struct). json.Marshal(v) returns bytes; json.Unmarshal(data, &v) writes back into your variable — and it needs a pointer (&v), because it has to mutate what you pass. Forget the & and nothing happens.

The single biggest gotcha, the one that catches everyone: encoding/json can only see exported fields — the ones starting with a capital letter. A lowercase field like host string is silently skipped, no error, it just vanishes from the output. The reason is package visibility — json lives in another package and Go’s rules block it from touching unexported fields. So if a field is mysteriously missing from your JSON, 90% of the time it’s lowercase.

We control the JSON shape with struct tags — the backtick strings after a field. json:"name" renames the field; ,omitempty drops it when it’s the zero value (empty string, 0, nil); json:"-" excludes it entirely.

type User struct {
	Name  string `json:"name"`
	Email string `json:"email,omitempty"`
}

You can decode two ways. Into a struct when you know the shape — type-safe and clean. Into a map[string]interface{} when the shape is dynamic — flexible, but every value comes out as interface{}, so you must type-assert (.(string)). Also remember: all JSON numbers decode as float64 in a map, never int.

For streams (HTTP bodies, files), don’t read everything into a []byte first — use json.NewDecoder(r.Body).Decode(&u) and json.NewEncoder(w).Encode(u), which read/write directly to an io.Reader/io.Writer. This is the idiomatic pattern in web handlers. And for unpredictable payloads, json.RawMessage defers decoding a chunk — keep it raw, peek at a discriminator field like Type, then unmarshal into the right struct.

Go struct
Name string `json:"name"`
Email string `json:"email,omitempty"`
JSON
{
"name": "Manish",
// email dropped if empty
}

Generics

Generics, added in Go 1.18 after a decade of community demand, let us write one function or type that works for many types instead of copy-pasting the same logic for int, string, float64, and so on. In simple language: we say “this works for any type T,” and the compiler fills in the real type at compile time.

The classic example is Max. Before generics you needed MaxInt, MaxFloat, and so on forever. Now it’s one function: func Max[T constraints.Ordered](a, b T) T. The [T constraints.Ordered] part is the type parameter listT is a placeholder, and the constraint says “T must be a type that supports <, >.” Usually the compiler infers T from the arguments, so you just call Max(3, 5) normally.

A constraint restricts what T can be — you can’t do a > b on any arbitrary type. The sources are: comparable (built-in, types supporting ==/!=, needed for map keys), the constraints package (Ordered, Integer, Float, Signed), any (the loosest — alias for interface{}), and custom interface constraints you write yourself. Custom ones use unions: ~int | ~int64 | ~float64. The | means union; the ~ (tilde) means “this type or any type whose underlying type is this,” so a type Celsius int still qualifies. Without ~ it matches int exactly.

Types can be generic too, not just functions — e.g. type Stack[T any] struct { items []T } gives you a fully type-checked Stack[string] and Stack[int] with no interface{} casting and no runtime surprises.

The real interview question is when to use them. Generics: same algorithm across many types, keeping type safety (Max, Map, Stack). Interfaces: different types share different behavior behind a method set — that’s polymorphism (io.Writer, fmt.Stringer). any: you genuinely don’t care about the type and accept type assertions — last resort. And the Go team’s own advice is explicit: reach for generics last, not first. They hurt readability, so they earn their place only when you’d otherwise copy-paste identical logic. Sprinkling [T any] everywhere is a code smell.

Generics
Same logic across many types, keep type safety. e.g. Max, Map, Stack[T]
Interfaces
Different types share behavior. e.g. io.Writer, fmt.Stringer
any
Truly don't care about type, willing to type-assert. Last resort.

Standard Library Essentials

Go’s standard library is famously “batteries included” — HTTP servers, JSON, crypto, testing, and time handling all ship with the language. A lot of what needs an npm install in Node is just there in Go, which is itself a real selling point: fewer dependencies means fewer supply-chain risks and less version churn. Here are the daily drivers.

fmt — formatting and printing. The verbs to memorize: %v (any value, default format), %+v (adds struct field names, e.g. {X:1}), %d (int), %s (string), %T (the type itself). fmt.Sprintf builds a string without printing it.

strings vs strconv — don’t confuse them. strings is text operations (Contains, Split, ToUpper, TrimSpace); strconv is number conversions (Atoi = string→int, Itoa = int→string, ParseFloat).

time — Go’s notorious quirk. Instead of YYYY-MM-DD, you format/parse against a fixed reference date: 2006-01-02 15:04:05. The numbers are a mnemonic — 1,2,3,4,5,6,7 → month, day, hour, minute, second, year, timezone. Interviewers love asking about this; just memorize the reference date. Durations are typed (3 * time.Hour), and helpers like time.Sleep, time.Since round it out.

os, io, bufioos talks to the OS (ReadFile/WriteFile, os.Args, os.Getenv). io defines the universal Reader/Writer interfaces — files, network connections, HTTP bodies, and buffers all implement them, so the same code works everywhere. bufio adds buffering; bufio.NewScanner reads a big file line-by-line efficiently.

sortsort.Ints, sort.Strings for the common cases, and sort.Slice(s, func(i, j int) bool {...}) with a less-function for custom ordering.

slices and maps (Go 1.21+) — generic helpers that kill repetitive loops: slices.Sort, slices.Contains, slices.BinarySearch, slices.Max, plus maps.Keys and maps.Clone. Before these, checking membership meant hand-writing a loop every time. If an interviewer asks how you’d check if a slice contains a value, say slices.Contains — it signals you know modern Go.

Testing & Production

Testing (Table-Driven)

Testing in Go is built right into the language — no frameworks to install, no config to fight with. We just write functions and run go test, and that “batteries included” experience is one of Go’s biggest selling points. The testing package is the standard-library tool for writing tests, and the go command knows how to find and run them. The rules are simple: tests live in files ending in _test.go (so math.go’s tests go in math_test.go, right next to it in the same package), and a test is just a function named TestXxx that takes one *testing.T argument. The name must start with capital Test and the next letter must be uppercase.

Inside a test we don’t get an assert keyword — Go deliberately ships none. We use plain if checks and report failures with t. The key distinction: t.Error / t.Errorf marks the test failed but keeps running the rest of the function, while t.Fatal / t.Fatalf fails and bails out immediately. In simple language: t.Error = “this is wrong, but carry on”; t.Fatal = “this is wrong, stop now” (used after a fatal setup step).

The idiomatic pattern — and the answer interviewers want — is the table-driven test. Instead of writing five near-identical test functions, we make a slice of case structs and loop over them, running each as a named subtest with t.Run. Each case shows up individually in the output, and we can run just one with go test -run TestAdd/negatives.

func TestAdd(t *testing.T) {
	tests := []struct {
		name    string
		a, b    int
		want    int
	}{
		{"positives", 2, 3, 5},
		{"with zero", 0, 7, 7},
		{"negatives", -2, -3, -5},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			if got := Add(tc.a, tc.b); got != tc.want {
				t.Errorf("Add(%d, %d) = %d; want %d", tc.a, tc.b, got, tc.want)
			}
		})
	}
}

Why we love it: adding a case is one line, the failure message pinpoints exactly which case broke, and subtests are individually runnable.

[]struct cases → loop → t.Run(name) subtest each
"positives" "with zero" "negatives"
↓ for _, tc := range tests ↓
PASS TestAdd/positives PASS TestAdd/with_zero FAIL TestAdd/negatives

Run tests with go test (current package), go test ./... (recursive, our most-used pattern), -v for verbose names, -run for a regex filter, and -cover for coverage. If we want assertions, the popular optional library is testify (assert.Equal(t, want, got)), but plenty of large codebases stick to the standard library.

Benchmarks & Profiling

Benchmarks tell us how fast our code is; profiling tells us where the time and memory actually go. Both ship inside the same testing package and run via the same go test command — no extra tooling. A benchmark is a function named BenchmarkXxx taking *testing.B, and the magic is b.N: the framework runs our loop b.N times, automatically cranking N up (maybe 100, maybe 10 million) until it gets a stable measurement. We never set b.N ourselves. If there’s expensive setup that shouldn’t be timed, we call b.ResetTimer() right before the loop so the clock starts fresh.

func BenchmarkParse(b *testing.B) {
	data := loadBigFile() // setup, don't time
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		Parse(data)
	}
}

Run with go test -bench=. and crucially add -benchmem to see allocations. Output reads left to right: BenchmarkAdd-8 1000000000 0.30 ns/op 0 B/op 0 allocs/op — the -8 is GOMAXPROCS (cores), then b.N, then ns/op (nanoseconds per op, lower is faster), B/op (bytes allocated), and allocs/op (heap allocations per op). The two numbers interviewers care about are ns/op (speed) and allocs/op (memory pressure) — allocations trigger garbage collection, so fewer allocs usually means faster, calmer code.

When a benchmark says “this is slow,” pprof says “this line is slow.” Capture a profile with go test -bench=. -cpuprofile=cpu.out -memprofile=mem.out, then explore it with go tool pprof cpu.out — inside, top shows the hottest functions, list Add shows line-by-line cost, and web opens a visual graph (needs Graphviz). For a live server we don’t benchmark — we add a blank import import _ "net/http/pprof", whose init() auto-registers /debug/pprof/* handlers, then point pprof at http://localhost:8080/debug/pprof/profile?seconds=30.

Finally, the race detector: concurrency bugs are sneaky — two goroutines touching the same memory unsynchronized. The -race flag instruments the code to catch these at runtime (go test -race ./... or go run -race main.go). If two goroutines access the same variable and at least one writes, it prints a loud report with both stack traces. It’s slower and memory-hungry, so we run it in CI/testing, not production. “How do you find data races?” → -race.

Go Tooling

One of the best things about Go is that the tooling is built in — compiler, test runner, formatter, doc viewer, dependency manager, all behind one go command. No webpack, no Babel, no debates about which formatter to use. In most languages we bolt on a dozen separate tools; in Go they come in the box.

The everyday commands: go run main.go (compile + run, great for dev), go build (compile to a binary, no run), go test ./... (run all tests recursively), and go install (build and drop the binary in $GOPATH/bin). The headline feature is that go build produces a single static binary with no runtime dependencies — copy that one file to a server and it just runs, which is why Go dominates containers and CLIs.

Formatting has exactly one true answer. gofmt (and its wrapper go fmt) rewrites code into the one canonical Go style — tabs, spacing, alignment, all decided for us — so there are no style debates. go fmt ./... formats the module; gofmt -d file.go shows a diff without writing. Most teams run it on save. goimports is the popular upgrade: it does everything gofmt does plus automatically adds and removes import statements (goimports -w main.go).

go vet is a built-in static checker. It doesn’t care about style — it flags likely bugs: a Printf with the wrong arg count, unreachable code, a struct-tag typo, a lock copied by value. It runs automatically as part of go test, and you can run it directly with go vet ./... — think of it as a free, fast first-pass bug catcher.

Dependencies live in Go modules. The two workhorse commands are go get github.com/some/pkg (add or upgrade a dependency) and go mod tidy (add missing deps, remove unused ones) — tidy is the housekeeper that syncs go.mod and go.sum to exactly what the code imports, and we run it before committing. For docs and codegen: go doc fmt.Println prints docs in the terminal (read straight from source comments, no internet), go doc -all strings dumps a whole package, and go generate ./... runs the //go:generate directives we’ve annotated (commonly to produce mocks or stringer methods — not automatic, we run it).

Beyond the built-ins, two third-party linters matter: staticcheck, a deep analyzer catching subtle bugs and dead code vet misses, and golangci-lint, a fast meta-linter bundling staticcheck, vet, and dozens more behind one config (golangci-lint run ./...). The latter is the de-facto CI standard in serious Go shops — worth name-dropping.

Project Structure & Dependencies

A Go project is organized into a module (the unit of versioning and dependencies) made of packages (folders of related code). Get this right and the project stays clean as it grows. In simple language: a module is the whole repo’s dependency bundle; a package is one folder of code. We start a module with go mod init github.com/pman47/myapp, which creates two files worth understanding — go.mod (module name, Go version, direct dependencies and their versions) and go.sum (cryptographic checksums of every dependency so builds are verifiable and tamper-proof).

Versions use semantic import versioning (v1.2.3 tags). The big rule: once a library hits v2 or higher, its major version becomes part of the import path (github.com/foo/bar/v2), letting v1 and v2 coexist without clashing. Pin a version with go get pkg@v1.4.0, then go mod tidy. Vendoring is optional — go mod vendor copies all dependencies into a local vendor/ folder for offline, reproducible builds; many teams skip it and rely on the module cache.

There’s no enforced layout, but the community converged on this convention:

myapp/
├── go.mod module + deps
├── go.sum dep checksums
├── cmd/ main packages (entry points)
│   └── api/
│       └── main.go
├── internal/ private — importable only inside myapp
│   ├── auth/
│   └── db/
├── pkg/ public — safe for others to import
│   └── client/
└── README.md

cmd/ holds one subfolder per executable (each a package main with main.go — an API plus a CLI would be cmd/api/ and cmd/cli/); pkg/ is library code we’re happy for outsiders to import. The interviewer favorite is the internal/ rule: it’s not just convention, the compiler enforces it. Code inside an internal/ directory can only be imported by code rooted in the parent of that folder — if another repo tries to import myapp/internal/db, the build fails. That’s how we keep implementation details from leaking.

Two more conventions. Package naming: short, lowercase, single word (http, json, user) — no underscores, no camelCase, matching the folder name, and avoiding stutter (user.New() not user.NewUser(), http.Server not http.HTTPServer, because callers already typed the package prefix). Circular imports are forbidden — if a imports b, then b cannot import a; the build refuses. We break a cycle by pulling shared types into a third lower-level package both can import, or by introducing an interface (dependency inversion). A cycle usually means two packages are really one concern split badly.

Idioms & Best Practices

This is the capstone grab-bag of idiomatic Go — none of it hard, but knowing these proverbs by name is the difference between “I write Go” and “I write idiomatic Go” in an interview. Go has a strong culture of “the right way,” much of it captured in short proverbs.

Accept interfaces, return structs. Take the most general type we can (an interface) as input, but return a concrete type (a struct). Accepting an interface lets callers pass anything that fits — easy to test, easy to swap; returning a concrete struct gives callers the full type with all its methods rather than a stripped-down interface. The standard library does this everywhere (io.Reader in, concrete types out). Relatedly, keep interfaces small — the best Go interfaces are one or two methods (io.Reader is just Read). “The bigger the interface, the weaker the abstraction.” Define interfaces where they’re used (the consumer), not where the type is defined.

Errors are values — handle them, don’t panic. Go has no exceptions; functions return an error as their last value and we check it right there. It’s verbose but explicit. We wrap errors with %w to add context as they bubble up (fmt.Errorf("opening config: %w", err)), and callers unwrap with errors.Is / errors.As. panic is reserved for truly unrecoverable situations (programmer bugs, impossible states), not normal control flow — a missing file is an error; indexing past a slice’s end is a panic.

Make the zero value useful. Design structs to work straight out of the box with no constructor — the classic examples are sync.Mutex (ready to lock the moment it’s declared) and bytes.Buffer (ready to write). Aim for “declare and go”; if users must call Init() first, we’ve made their life harder.

Prefer composition over inheritance. Go has no class inheritance — instead we embed one struct inside another and its methods get promoted up. A Server that embeds Logger gets Log() for free. We build big things from small things rather than extending a base class; embedding is the only inheritance-like mechanism Go gives us, by design.

A little copying beats a little dependency. If we’d pull in a whole package for one tiny helper, it’s often cleaner to copy those few lines — fewer dependencies means less to break, audit, and version-juggle. Naming stays short with no stutter (short names for short scopes; capital = exported, lowercase = unexported — that’s the entire visibility system). And finally, gofmt everything: “Gofmt’s style is no one’s favorite, yet gofmt is everyone’s favorite.” Just run it and move on. It’s all in Effective Go.