Go

All 34 notes on one page

Fundamentals

1

What is Go & Why use it

beginner go basics

Go (also called golang) is a programming language made at Google back in 2009. It was built by people who were tired of slow builds and overly complicated code in big projects.

In simple language, Go is what you get when smart engineers ask: “Can we have a language that’s as fast as C, but as easy to read as Python?”

Let’s break down the words people always throw around when describing Go.

The buzzwords, explained

Compiled — we turn our code into a machine-readable binary before running it. No interpreter sitting between our code and the CPU at runtime. That’s why Go programs start instantly and run fast.

Statically typed — every variable has a type that’s known at compile time. If we try to put a string into an int, the compiler yells at us before the program even runs. This catches a whole class of bugs early.

Garbage collected — we don’t manually free memory like in C/C++. Go has a background process (the garbage collector) that cleans up memory we’re no longer using. So we get the safety of automatic memory management without the heavy runtime of, say, Java.

Built for concurrency — doing many things at once is baked into the language with goroutines and channels. Think of a goroutine like a super-cheap thread we can spin up with one keyword. This is Go’s killer feature.

Simple by design — Go intentionally has very few features. No classes, no inheritance, no generics for years (added later), no exceptions. The idea is: less stuff to learn, fewer ways to write confusing code.

Why people actually love it

Here’s the stuff that makes Go a joy in real backend work:

  • Fast compile times — even huge projects build in seconds. Compare that to C++ where you can go grab a coffee.
  • Single static binarygo build spits out one file with everything inside it. No “install these 40 dependencies on the server first.” We just copy the binary and run it. This is why Go and Docker are best friends — tiny images, no runtime to install.
  • Great standard library — an HTTP server, JSON parsing, crypto… it’s all in the box.
  • Readablegofmt formats everyone’s code the same way, so no more arguments about tabs vs spaces.

Who uses Go

A lot of the infrastructure running the modern internet is written in Go:

  • Docker — containers
  • Kubernetes — container orchestration
  • Terraform — infrastructure as code
  • Prometheus — monitoring
  • CockroachDB, etcd, and tons of cloud-native tools

If we’re doing backend, DevOps, or cloud work, we’ll bump into Go constantly.

A quick “hello world”

package main // every Go program starts in package main

import "fmt" // fmt is the standard formatting/printing library

func main() {
	// this runs when we execute the program
	fmt.Println("Hello, Go!")
}

Run it with:

go run main.go

How it compares to other languages

  • vs Python — Go is way faster and catches type errors at compile time, but Python is quicker to prototype and has a bigger data/ML ecosystem.
  • vs Java — similar performance ballpark, but Go has a tiny runtime, faster startup, and far less ceremony (no public static void main boilerplate hell).
  • vs C/C++ — Go gives us memory safety and garbage collection, so we trade a little raw speed for a lot less foot-shooting.
  • vs Node.js — both are great for I/O-heavy backends, but Go uses real concurrency (goroutines on multiple cores) instead of a single-threaded event loop.

When NOT to use Go

Go isn’t magic. We should reach for something else when:

  • We’re doing heavy data science / ML — Python’s ecosystem (NumPy, PyTorch) crushes Go here.
  • We need a rich GUI desktop app — Go’s UI story is weak.
  • We need extreme low-level control over memory and timing (real-time systems, kernels) — the garbage collector gets in the way; use C/C++/Rust.
  • We’re building a quick one-off script — Python or Bash is faster to write for that.

Interview soundbite

Go is a compiled, statically typed, garbage-collected language built for simplicity and concurrency. Its standout traits are fast compile times, a single self-contained binary that makes deployment trivial, and cheap built-in concurrency via goroutines — which is exactly why cloud infrastructure like Docker and Kubernetes is written in it.


2

A variable is just a named box that holds a value we can change later. A constant is the same idea, except its value is locked in forever once we set it.

Go gives us a few ways to declare variables, and one slick trick (iota) for making enums. Let’s walk through all of it.

Declaring variables with var

The full, explicit way uses the var keyword. We write var, then the name, then the type.

var name string = "Manish" // explicit type and value
var age int = 28           // type is int

// Go can infer the type from the value, so we can drop it:
var city = "Mumbai" // Go figures out this is a string

// If we don't give a value, Go uses the zero value (more on that later):
var count int // count is 0

Short declaration :=

Inside functions, there’s a shortcut. The := operator declares and assigns in one go, and Go infers the type.

In simple language, := means “make a new variable and set it to this.”

func main() {
	score := 100      // same as: var score int = 100
	name := "Go"      // string
	pi := 3.14        // float64

	score = 200 // we can reassign with plain = (no colon)
}

The only difference is that := only works inside functions. At the package level (outside any function) we must use var.

Constants

Use const for values that never change. The big rule: a constant’s value must be known at compile time — we can’t set it from a function call or user input.

const pi = 3.14159
const greeting = "hello"

// We can group them in a block for tidiness:
const (
	maxUsers   = 100
	appName    = "Gyaan"
	isReleased = true
)

Typed vs untyped constants

This is a subtle but interview-worthy detail.

An untyped constant has no fixed type until it’s used. This makes it flexible.

A typed constant locks itself to one type.

const untyped = 5     // untyped — can act as int, float64, etc.
const typed int = 5   // typed — strictly an int

var f float64 = untyped // OK — untyped 5 happily becomes a float64
// var g float64 = typed // ERROR — typed int won't auto-convert to float64

Think of an untyped constant like a chameleon — it takes on whatever type the situation needs.

iota — the enum trick

Go has no built-in enum keyword. Instead we use iota, which is a counter that auto-increments inside a const block.

In simple language, iota starts at 0 and goes up by 1 for each line in the block. It’s perfect for numbering related constants without typing 0, 1, 2 by hand.

type Weekday int

const (
	Sunday    Weekday = iota // 0
	Monday                   // 1 (iota keeps counting automatically)
	Tuesday                  // 2
	Wednesday                // 3
	Thursday                 // 4
	Friday                   // 5
	Saturday                 // 6
)

We only write iota once — every line below it keeps incrementing.

It even works with expressions. A classic example is defining byte sizes by shifting bits:

const (
	_  = iota             // skip 0 with the blank identifier _
	KB = 1 << (10 * iota) // 1 << 10 = 1024
	MB                    // 1 << 20
	GB                    // 1 << 30
)

Interview soundbite

Go has three declaration styles: explicit var x type = val, inferred var x = val, and the short form x := val which only works inside functions. Constants are compile-time fixed and can be typed or untyped — untyped ones flex to whatever type they’re used as. And since Go has no enum keyword, we use iota, an auto-incrementing counter inside a const block, to generate sequential values.


3

Data Types & Zero Values

beginner go types

A data type tells Go what kind of value a variable holds — a number, some text, a true/false, and so on. Go is statically typed, so every value has a type, and the compiler checks it.

Let’s go through the basic types first, then hit the big interview favorite: zero values.

The basic types

var i int = 42        // platform-sized integer (32 or 64 bit)
var big int64 = 99999 // explicitly 64-bit integer
var price float64 = 9.99 // floating-point number (use float64 by default)
var name string = "Go"   // text
var ok bool = true       // true or false
var b byte = 'A'         // alias for uint8 — a single raw byte (65)
var r rune = ''        // alias for int32 — a single Unicode character

A few notes that trip people up:

  • int sizes itself to the machine (64-bit on modern systems). When in doubt, just use int.
  • float64 is the default float — prefer it over float32 unless we have a memory reason.
  • byte is just a nicer name for uint8. rune is just a nicer name for int32. They exist to make intent clear.

byte vs rune (the string gotcha)

In simple language, a byte is one raw 8-bit chunk, while a rune is one full character (which might be several bytes).

This matters because Go strings are UTF-8 encoded. An emoji or a Hindi character takes multiple bytes but is a single rune.

s := "héllo"
fmt.Println(len(s))           // 6 — counts BYTES, not characters! (é is 2 bytes)
fmt.Println(len([]rune(s)))   // 5 — converting to runes counts CHARACTERS

for i, r := range s {
	// range over a string gives us runes, not bytes
	fmt.Printf("%d: %c\n", i, r)
}

So len(string) gives bytes, not character count. Remember that — it’s a classic gotcha.

The zero value concept

Here’s the Go-specific idea interviewers love. In Go, every variable is automatically given a “zero value” if we don’t initialize it. There’s no such thing as an uninitialized variable holding garbage memory.

Think of it like this: Go refuses to hand us a box with random junk inside. It always gives us a clean, sensible default.

This kills a whole category of bugs. In C, an uninitialized int could be any random number. In Java, an uninitialized object is null and blows up. In Go, things just start at a safe default.

Here’s what each type defaults to:

Type
Zero Value
int, int64, byte, rune
0
float64
0.0
bool
false
string
"" (empty string)
pointer, slice, map, channel, func, interface
nil
var n int       // 0
var f float64   // 0.0
var s string    // "" (empty, not nil!)
var ready bool  // false
var p *int      // nil

fmt.Println(n, f, s == "", ready, p == nil) // 0 0 true false true

Notice that a string’s zero value is the empty string "", not nil. Only the reference-y types (pointers, slices, maps, channels, funcs, interfaces) are nil.

This means we can declare a variable and start using it safely. For example, a zero-value int is ready to be added to, and a zero-value bool is already false — no setup needed.

Interview soundbite

Go has the usual basic types, plus byte (alias for uint8) and rune (alias for int32) for working with bytes versus full Unicode characters in strings. The killer concept is zero values: every variable gets a sane default automatically — 0 for numbers, "" for strings, false for bools, and nil for reference types — so there’s no such thing as an uninitialized garbage value in Go.


4

Functions

beginner go functions

A function is a named block of code that takes some inputs, does work, and gives back a result. Nothing new there.

But Go functions have a few twists that show up in almost every interview. Let’s go through them.

The basics

We declare a function with func, list the parameters with their types, and put the return type after the parentheses.

// takes two ints, returns an int
func add(a int, b int) int {
	return a + b
}

// if params share a type, we can write it once
func multiply(a, b int) int {
	return a * b
}

Multiple return values (the idiomatic part)

Here’s the first thing that surprises people coming from other languages: a Go function can return more than one value. This is everywhere in Go.

func divmod(a, b int) (int, int) {
	return a / b, a % b // return both quotient and remainder
}

func main() {
	q, r := divmod(17, 5) // q = 3, r = 2
	fmt.Println(q, r)
}

The most famous use of this is returning a result and an error together. This is the Go pattern — we’ll see it constantly.

import "strconv"

func main() {
	// strconv.Atoi returns (the number, an error)
	value, err := strconv.Atoi("42")
	if err != nil {
		// something went wrong, handle it
		fmt.Println("bad input")
		return
	}
	fmt.Println(value) // 42
}

Think of value, err := as Go’s polite way of saying “here’s your answer, and here’s whether it worked.” We’ll cover error handling deeply later — for now just know this shape.

Named return values

We can give the return values names. When we do, a bare return automatically sends back whatever those named variables currently hold.

// x and y are pre-declared and start at their zero values
func split(sum int) (x, y int) {
	x = sum * 4 / 9
	y = sum - x
	return // "naked" return — sends back x and y automatically
}

This is handy for short functions, but don’t overuse it — in long functions it makes the code harder to follow.

Variadic parameters

A variadic function accepts any number of arguments of the same type. We mark it with ... before the type.

In simple language, ...int means “zero or more ints, please.”

func sum(nums ...int) int {
	total := 0
	for _, n := range nums { // nums behaves like a slice here
		total += n
	}
	return total
}

func main() {
	fmt.Println(sum(1, 2, 3))    // 6
	fmt.Println(sum())           // 0 — zero args is fine
	nums := []int{4, 5, 6}
	fmt.Println(sum(nums...))    // spread a slice with ...
}

fmt.Println itself is variadic — that’s why we can pass it any number of things.

Functions as first-class values

In Go, functions are just values. We can store them in variables, pass them as arguments, and return them from other functions. This is what “first-class” means.

func apply(nums []int, op func(int) int) []int {
	result := make([]int, len(nums))
	for i, n := range nums {
		result[i] = op(n) // call the passed-in function
	}
	return result
}

func main() {
	double := func(x int) int { return x * 2 } // function stored in a variable
	fmt.Println(apply([]int{1, 2, 3}, double))  // [2 4 6]
}

Closures

A closure is a function that “remembers” variables from the scope where it was created — even after that scope has finished running.

Think of it like a backpack: the inner function carries around the variables it needs.

func counter() func() int {
	count := 0          // this lives on inside the returned function
	return func() int {
		count++         // each call remembers and updates count
		return count
	}
}

func main() {
	next := counter()
	fmt.Println(next()) // 1
	fmt.Println(next()) // 2
	fmt.Println(next()) // 3 — count persists between calls
}

Each call to counter() makes a fresh, independent count. Closures are great for generators, callbacks, and keeping a little private state.

Interview soundbite

Go functions can return multiple values, which powers the idiomatic value, err := error pattern. They support named returns, variadic parameters with ..., and they’re first-class — we can pass them around as values. Closures take this further: an inner function captures and remembers variables from its surrounding scope, like carrying them in a backpack.


5

Packages & Go Modules

beginner go modules tooling

A package is a folder of Go files that work together. A module is a collection of packages with a version, basically one project or library. These two concepts are how Go organizes and shares code.

Let’s start small and build up.

Packages

Every Go file begins by declaring which package it belongs to.

package main // this file is part of the "main" package

The special package main is where executable programs live — it must have a func main(). Any other name (like utils or auth) is a regular library package meant to be imported by other code.

Files in the same folder must all share the same package name.

Exported vs unexported (the capitalization rule)

This is Go’s clever trick for access control, and it’s a guaranteed interview question.

In simple language: if a name starts with a capital letter, it’s public (exported). If it starts with lowercase, it’s private (unexported) to its own package.

There’s no public or private keyword. The casing is the rule.

package mathx

func Add(a, b int) int { // Capital A — exported, usable from other packages
	return a + b
}

func subtract(a, b int) int { // lowercase s — private to this package
	return a - b
}

So when we import a package and type mathx.Add(...), it works, but mathx.subtract(...) won’t even compile from outside.

Importing

We pull in other packages with import.

import (
	"fmt"          // standard library
	"strings"      // standard library
	"github.com/user/repo/auth" // a third-party or our own module's package
)

We then use the package’s last name as the prefix: fmt.Println, strings.ToUpper, auth.Login.

Modules — go mod init

A module is the unit Go uses to track dependencies and versions. We create one with:

go mod init github.com/manish/myapp

That module path is usually the repo URL where the code lives. Running this creates a go.mod file.

go.mod and go.sum

go.mod is the heart of the project. It lists the module name, the Go version, and every dependency with its version. Think of it like package.json in Node.

module github.com/manish/myapp

go 1.22

require github.com/gorilla/mux v1.8.1

go.sum is a lockfile of cryptographic checksums. It records the exact hash of each dependency so nobody can swap out a package with tampered code. Think of it like package-lock.json. We commit both files to git but never edit go.sum by hand.

Adding dependencies — go get

To add a third-party package:

go get github.com/gorilla/mux

This downloads it and updates go.mod and go.sum automatically. To clean up unused deps and add any missing ones, run:

go mod tidy

go mod tidy is the one we run before committing — it keeps go.mod honest.

Building and running — go build vs go run

go run main.go   # compile + run in one step, no file left behind. Great for dev.

go build         # compile into a binary in the current folder. Doesn't run it.
./myapp          # then we run the binary ourselves

go run is for quick iteration. go build is for producing the actual binary we ship.

GOPATH is dead

Worth knowing if we read old tutorials. Years ago, all Go code had to live inside one magic folder called $GOPATH/src, and dependency management was a mess.

Since Go 1.11+, modules replaced GOPATH. Now our project can live anywhere on disk — we just need a go.mod file. If a tutorial tells us to set up GOPATH, it’s outdated.

Interview soundbite

Go organizes code into packages (a folder of files) and modules (a versioned project). Access control is done purely by capitalization — capitalized names are exported/public, lowercase ones are private to the package. A module is created with go mod init, tracked in go.mod (like package.json) and go.sum (a checksum lockfile), and the old GOPATH model has been dead since modules arrived in Go 1.11.


Types & Data Structures

6

Arrays vs Slices

intermediate go slices arrays

This is THE classic Go interview topic. If we get one data structure question, it’s usually this.

Let’s start with the difference.

An array in Go has a fixed size baked into its type. [3]int and [4]int are literally different types. The size is part of the type.

A slice is a flexible, growable view over an array. It’s what we use 99% of the time.

var arr [3]int          // fixed-size array, length is always 3
arr[0] = 10

s := []int{1, 2, 3}     // slice, no size in the brackets
s = append(s, 4)        // slices can grow

Why arrays are weird: they’re value types

In simple language, when we pass an array to a function or assign it, Go copies the whole thing. Every element.

a := [3]int{1, 2, 3}
b := a          // b is a FULL copy
b[0] = 99
// a is still {1, 2, 3} — completely independent

That copying behavior surprises people. This is why we rarely use arrays directly. We use slices.

What a slice actually is

In simple language, a slice is a tiny struct with three fields:

  • ptr — a pointer to where the data lives (the backing array)
  • len — how many elements we can currently see
  • cap — how many elements 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)

make and slicing

make lets us pre-build a slice with a length and an optional capacity.

s := make([]int, 2, 5)  // len 2, cap 5 — two zero-value ints, room for 5
fmt.Println(len(s), cap(s)) // 2 5

nums := []int{10, 20, 30, 40}
sub := nums[1:3]        // a view: elements at index 1 and 2 → {20, 30}
fmt.Println(sub)        // [20 30]

The key thing: nums[1:3] does NOT copy. sub points into the same backing array as nums. Think of it like a window onto the same data.

How append grows: capacity doubling

When we append and there’s spare capacity, Go writes into the existing backing array. Fast.

When we run out of capacity, Go allocates a new, bigger backing array (roughly doubling for small slices), copies everything over, and returns a slice pointing to the new array.

s := make([]int, 0, 2)
fmt.Println(len(s), cap(s)) // 0 2
s = append(s, 1, 2)
fmt.Println(len(s), cap(s)) // 2 2 — full
s = append(s, 3)
fmt.Println(len(s), cap(s)) // 3 4 — capacity grew (doubled)

This is why we always write s = append(s, x) — append may return a different slice.

The big gotcha: shared backing array

This trips up almost everyone. When two slices share a backing array, appending into the spare capacity of one can silently overwrite the other.

base := []int{1, 2, 3, 4, 5}
a := base[0:2]              // {1, 2}, but cap is 5 (sees the rest)
a = append(a, 99)          // writes into base's slot at index 2!
fmt.Println(base)          // [1 2 99 4 5] — base got mutated!

We appended to a, but because a still had spare capacity inside base, the append clobbered base[2]. If we want a safe, independent copy, we use a full slice expression base[0:2:2] (caps the capacity) or just copy into a fresh slice.

Interview soundbite

An array has its size fixed in its type and copies by value; a slice is a lightweight header (pointer, length, capacity) that views a backing array. The classic gotcha is that slicing and appending can share that backing array, so an append into spare capacity can mutate another slice — append may also reallocate and double the capacity, which is why we always reassign the result.


7

Maps

intermediate go maps concurrency

A map is Go’s built-in key-value store. Think of it like a dictionary or hash table — we look things up by a key instead of an index.

We create one with make, or with a literal.

ages := make(map[string]int)   // empty map, keys are strings, values are ints
ages["manish"] = 27            // set
fmt.Println(ages["manish"])    // get → 27

scores := map[string]int{      // map literal
    "math": 90,
    "cs":   95,
}

The comma-ok idiom

Here’s the trap. If we read a key that doesn’t exist, Go does NOT error. It returns the zero value of the value type.

fmt.Println(ages["nobody"]) // 0 — but did we store 0, or is the key missing?

We can’t tell the difference from the value alone. So Go gives us a second return value — a boolean that says “yes, this key actually exists.” This is the comma-ok idiom.

v, ok := ages["nobody"]
fmt.Println(v, ok)  // 0 false — the key is missing
v2, ok2 := ages["manish"]
fmt.Println(v2, ok2) // 27 true — the key exists

The only difference between the two reads is that second value. We use it any time a zero value is ambiguous.

Deleting

We remove a key with the built-in delete. Deleting a key that isn’t there is a no-op — no panic, nothing.

delete(ages, "manish")  // removes the key
delete(ages, "ghost")   // safe even if missing

Maps are reference-like

In simple language, when we pass a map to a function, we’re not copying it. We’re passing a reference to the same underlying data. So a function can mutate our map.

func addOne(m map[string]int) {
    m["count"]++   // this affects the caller's map too
}

This is the opposite of arrays, which copy. It’s because internally a map value is a pointer to the real hash table.

Iteration order is random (gotcha)

This one shows up in interviews. Go intentionally randomizes map iteration order. Run the same loop twice and the keys may come out in a different order.

for k, v := range scores {
    fmt.Println(k, v)   // order is NOT guaranteed, changes between runs
}

Why? So we don’t accidentally write code that depends on order. If we need sorted output, we collect the keys into a slice and sort them.

Maps are NOT safe for concurrent writes

Big one. If two goroutines write to the same map at the same time, Go will crash the program with a “concurrent map writes” panic. It’s a hard, intentional crash.

For concurrent access we either guard the map with a sync.Mutex, or use sync.Map for certain read-heavy patterns.

var mu sync.Mutex
counts := map[string]int{}

// inside a goroutine:
mu.Lock()
counts["hits"]++
mu.Unlock()

nil map: reads OK, writes panic

A map declared but never initialized is nil. Reading from a nil map is fine — we just get zero values. But writing to a nil map panics.

var m map[string]int   // nil map (no make)
fmt.Println(m["x"])    // 0 — reading is fine
m["x"] = 1             // PANIC: assignment to entry in nil map

So we always make a map before writing to it.

Interview soundbite

Maps return the zero value for missing keys, so we use the comma-ok idiom v, ok := m[k] to tell “missing” from “zero.” Iteration order is deliberately randomized, maps are reference-like so functions can mutate them, and they are not safe for concurrent writes — concurrent writes panic, so we reach for a mutex or sync.Map. Reading a nil map is fine but writing to one panics.


8

Structs & Embedding

intermediate go structs embedding

A struct is just a way to group related fields under one type. Think of it like a record or a row — a bundle of named values that belong together.

type User struct {
    Name string
    Age  int
}

Creating them: literals

There are a few ways to make a struct value. The named-field form is the one we should prefer because it survives field reordering.

u1 := User{Name: "Manish", Age: 27}  // named fields — clearest
u2 := User{"Aisha", 30}              // positional — fragile, order matters
u3 := User{Name: "Bob"}              // Age defaults to its zero value (0)
fmt.Println(u1, u2, u3)

Anonymous structs

Sometimes we want a one-off struct without naming the type — handy for quick config or test data. We just declare the shape and the value together.

point := struct {
    X, Y int
}{X: 1, Y: 2}
fmt.Println(point.X, point.Y)  // 1 2

Struct tags (json)

Tags are little strings of metadata attached to fields. The encoding/json package reads them to decide JSON key names. Without a tag, JSON uses the field name as-is.

type Product struct {
    ID    int     `json:"id"`
    Name  string  `json:"name"`
    Price float64 `json:"price,omitempty"` // omitempty: skip if zero value
}
// marshals to: {"id":1,"name":"Pen"}  (price dropped when 0)

The omitempty part means “leave this key out of the JSON if the value is empty/zero.” Very common in API code.

Embedding: composition over inheritance

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 is “this type IS-A / HAS the abilities of that type” by composing them, not by subclassing.

type Animal struct {
    Name string
}

func (a Animal) Speak() string {  // method on Animal
    return a.Name + " makes a sound"
}

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

Method promotion

Because Animal is embedded, the Dog automatically “promotes” Animal’s fields and methods. We can call them directly on the Dog as if they were its own.

d := Dog{
    Animal: Animal{Name: "Rex"},
    Breed:  "Lab",
}
fmt.Println(d.Name)      // "Rex"  — promoted field
fmt.Println(d.Speak())   // "Rex makes a sound" — promoted method

That’s the whole trick. We didn’t redeclare Name or rewrite Speak() — the embedded Animal gave them to Dog. If Dog defined its own Speak(), that would take priority (the outer one shadows the inner one), which is how we “override” behavior.

Interview soundbite

Structs group related fields, and we prefer named-field literals so reordering doesn’t break things. Go has no inheritance — instead we embed one struct inside another without a field name, and the outer type promotes the inner type’s fields and methods. That’s Go’s composition-over-inheritance, and an outer method with the same name shadows the embedded one.


9

Methods & Receivers

intermediate go methods receivers

A method is just a function with a special receiver argument that attaches it to a type. Think of it like “this function belongs to this type.”

type Rect struct {
    W, H int
}

func (r Rect) Area() int {   // (r Rect) is the receiver
    return r.W * r.H
}

rect := Rect{W: 3, H: 4}
fmt.Println(rect.Area())     // 12

That (r Rect) bit between func and the name is the receiver. It’s what makes Area a method on Rect instead of a plain function.

The big one: value vs pointer receiver

This is a top interview question. There are two flavors of receiver:

  • Value receiver (r Rect) — the method gets a copy of the value. Changes inside don’t affect the original.
  • Pointer receiver (r *Rect) — the method gets a pointer to the original. Changes inside DO affect the original.

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
func (r Rect) ScaleCopy() {   // value receiver
    r.W *= 2                  // mutates the copy only
}

func (r *Rect) Scale() {      // pointer receiver
    r.W *= 2                  // mutates the original
}

box := Rect{W: 3, H: 4}
box.ScaleCopy()
fmt.Println(box.W)  // 3 — unchanged, we scaled a copy
box.Scale()
fmt.Println(box.W)  // 6 — changed, we scaled the real thing

When to use which

Two simple rules cover almost everything:

  1. If the method needs to modify the receiver, use a pointer receiver.
  2. If the struct is large (copying is expensive), use a pointer receiver to avoid the copy.

And one consistency rule: if any method on a type uses a pointer receiver, make them all pointer receivers. Mixing them is confusing and can cause subtle interface issues.

Small, read-only, immutable-feeling types (like a Point or a time.Time) are fine with value receivers.

The addressability rule

Here’s the gotcha. To call a pointer-receiver method, Go needs to take the address of the value. That only works if the value is addressable — basically, it has to live somewhere we can point at (a variable, a slice element, a struct field).

type Counter struct{ n int }
func (c *Counter) Inc() { c.n++ }

c := Counter{}
c.Inc()              // OK — c is a variable, Go auto-takes &c
fmt.Println(c.n)     // 1

// Counter{}.Inc()   // COMPILE ERROR — a literal isn't addressable

Go is kind enough to auto-insert the & for us when calling c.Inc() on a variable. But a temporary value like Counter{} or a map element has no fixed address, so the pointer-receiver call won’t compile.

Interview soundbite

A value receiver works on a copy so mutations are lost; a pointer receiver works on the original so mutations stick and we avoid copying big structs. The rule of thumb: use a pointer receiver if we mutate or the struct is large, and keep receivers consistent across a type. Pointer-receiver methods need an addressable value — Go auto-takes the address of a variable, but a non-addressable literal won’t compile.


10

Interfaces

intermediate go interfaces

An interface is a set of method signatures. It says “anything with these methods can be used here.” It describes behavior, not data.

type Speaker interface {
    Speak() string
}

Anything that has a Speak() string method satisfies Speaker. Simple as that.

Implicit satisfaction (interview favorite)

Here’s the thing that surprises people coming from Java or C#. In Go there is no implements keyword. A type satisfies an interface automatically just by having the right methods. We never declare the relationship.

type Dog struct{}
func (d Dog) Speak() string { return "Woof" }  // that's it

var s Speaker = Dog{}   // Dog satisfies Speaker — no "implements" anywhere
fmt.Println(s.Speak())  // Woof

In simple language: if it walks like a duck and quacks like a duck, Go treats it as a duck. We didn’t have to tell Go that Dog is a Speaker.

Small interfaces are the Go way

Go favors tiny interfaces — often just one method. The standard library is full of them. io.Reader and io.Writer each have a single method, and huge chunks of Go plug together through them.

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

The smaller the interface, the more types satisfy it, and the more reusable our code is. “The bigger the interface, the weaker the abstraction” is a famous Go proverb.

The empty interface: interface{} / any

An interface with zero methods is satisfied by everything, since every type has at least zero methods. That’s the empty interface, written interface{} or, since Go 1.18, the alias any.

var x any        // can hold literally anything
x = 42
x = "hello"
x = []int{1, 2}

We use it when we genuinely don’t know the type ahead of time (like fmt.Println, which takes ...any). But it throws away type safety, so we use it sparingly.

What an interface actually holds: (type, value)

In simple language, an interface value is a little two-part box. It stores both the concrete type and the value of whatever we put in it.

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

This two-part design is exactly what causes the next gotcha.

The famous nil-interface gotcha

An interface is nil only when both its type half and its value half are nil. If we stuff a nil pointer into an interface, the interface holds a type (the pointer’s type) — so it is NOT nil, even though the value inside is nil.

type MyErr struct{}
func (e *MyErr) Error() string { return "boom" }

func doThing() error {
    var p *MyErr = nil   // a nil pointer
    return p             // BUG: returns a non-nil interface wrapping a nil *MyErr
}

err := doThing()
fmt.Println(err == nil)  // false! — surprises everyone

The interface isn’t nil because its type half is *MyErr. This is why we always return a literal nil for the no-error case, never a typed nil pointer.

Interface satisfaction visual

Dog
has Speak() string
→ automatically satisfies →
Speaker
needs Speak() string

Interview soundbite

Go interfaces are satisfied implicitly — no implements keyword, a type just needs the right methods, which is duck typing. We favor small interfaces like io.Reader, and any is the empty interface that holds anything. Under the hood an interface is a (type, value) pair, and it’s nil only when both halves are nil — which is why returning a nil typed pointer gives a non-nil interface, the classic nil-interface trap.


11

Pointers

intermediate go pointers

A pointer is a value that holds the memory address of another value. Instead of “the data,” a pointer is “where the data lives.”

Two operators do all the work:

  • &x gives us the address of x (a pointer to it).
  • *p gives us the value that the pointer p points at (we call this dereferencing).
x := 10
p := &x          // p is a *int — a pointer to x
fmt.Println(*p)  // 10 — follow the pointer to read the value
*p = 20          // write through the pointer
fmt.Println(x)   // 20 — x changed because p pointed at it

Think of & as “give me the address of” and * as “go to that address.”

No pointer arithmetic

If we’ve used C, forget one habit. Go has no pointer arithmetic. We cannot do p++ to walk through memory. We can only point at something, read it, or write it.

Why? Safety. Pointer arithmetic is a huge source of bugs and security holes. Go’s garbage collector also needs to know exactly what’s a pointer, so it bans the math. This makes Go pointers much safer than C pointers.

Why pass pointers

Two real reasons we reach for pointers:

1. Mutation. Go passes everything by value (a copy). If a function should change the caller’s data, it needs a pointer.

func doubleCopy(n int)  { n *= 2 }      // changes a copy — useless
func doublePtr(n *int)  { *n *= 2 }     // changes the real value

v := 5
doubleCopy(v)
fmt.Println(v)  // 5 — unchanged
doublePtr(&v)
fmt.Println(v)  // 10 — changed

2. Avoid copying big structs. Passing a large struct by value copies every field. Passing a pointer copies just one address. Cheaper.

type BigConfig struct { /* ...dozens of fields... */ }
func process(c *BigConfig) { /* no expensive copy */ }

new vs &T{}

Two ways to allocate and get a pointer. They mostly do the same thing.

  • new(T) allocates a zeroed T and returns a *T.
  • &T{...} makes a struct literal (optionally with field values) and returns its address.
type User struct{ Name string }

a := new(User)          // *User, Name is "" (zero value)
b := &User{Name: "M"}   // *User, Name is "M"
fmt.Println(a.Name, b.Name) // "" M

In practice we almost always use &T{...} because it lets us set fields right away. new is rare — handy mainly when we just want a zeroed pointer to a basic type. Note Go auto-dereferences for us: we write a.Name, not (*a).Name.

nil pointers

A pointer that points at nothing is nil. Reading a field through a nil pointer panics, so we guard against it.

var u *User           // nil pointer
// fmt.Println(u.Name) // PANIC: nil pointer dereference
if u != nil {
    fmt.Println(u.Name)
}

The classic “nil pointer dereference” panic is just us following a pointer that points nowhere. Always check before dereferencing if a pointer could be nil.

Interview soundbite

& takes an address, * follows it, and Go deliberately has no pointer arithmetic for safety and the garbage collector. We pass pointers for two reasons: to mutate the caller’s data (since Go copies by value) and to avoid copying large structs. We usually allocate with &T{...} over new, and we guard against nil pointers to avoid the dereference panic.


12

Type Assertions & Type Switches

intermediate go interfaces type-assertion

Once a value is sitting inside an interface (like any), we’ve hidden its concrete type. A type assertion is how we pull that concrete type back out.

In simple language: “I believe this interface is actually holding a string — give me the string.”

var x any = "hello"
s := x.(string)     // assert: x holds a string
fmt.Println(s)      // hello

The syntax is x.(T) — the value, a dot, and the type in parentheses.

The safe form: comma-ok

What if we’re wrong about the type? The single-value form above will panic. So most of the time we use the two-value, comma-ok form, which never panics.

var x any = "hello"
s, ok := x.(int)    // is x an int? no.
fmt.Println(s, ok)  // 0 false — zero value + false, no panic

n, ok := x.(string)
fmt.Println(n, ok)  // hello true

The second value ok tells us whether the assertion succeeded. If it failed, we get the zero value and false instead of a crash. This is the same comma-ok pattern we saw with maps.

The panic form (use carefully)

The single-value form is fine only when we’re 100% certain of the type — otherwise it blows up.

var x any = 42
// s := x.(string)  // PANIC: interface conversion, x is int not string

So the rule of thumb: prefer v, ok := x.(T) unless a wrong type genuinely should be a programmer error worth crashing on.

Type switches: checking many types at once

When a value could be one of several types, chaining a bunch of assertions is ugly. A type switch handles it cleanly. The magic syntax is switch v := x.(type).

func describe(x any) string {
    switch v := x.(type) {       // note: x.(type) only works inside switch
    case int:
        return fmt.Sprintf("int: %d", v*2)   // v is an int here
    case string:
        return fmt.Sprintf("string of len %d", len(v)) // v is a string here
    case bool:
        return fmt.Sprintf("bool: %t", v)
    default:
        return "unknown type"
    }
}

The clever part: inside each case, the variable v is automatically the right concrete type. In the int case, v is an int; in the string case, v is a string. Go narrows it for us, so we can call type-specific operations like v*2 or len(v) without another assertion.

fmt.Println(describe(10))      // int: 20
fmt.Println(describe("go"))    // string of len 2
fmt.Println(describe(true))    // bool: true
fmt.Println(describe(3.14))    // unknown type

How it ties back to interfaces

Remember, an interface stores a (type, value) pair. Assertions and type switches are just us inspecting that hidden type half and getting the value back out in its real form. They’re the standard way to handle an any or to recover the concrete type behind a small interface like error.

Interview soundbite

A type assertion x.(T) pulls the concrete type out of an interface; the single-value form panics on mismatch, so we prefer the comma-ok form v, ok := x.(T) which returns false instead of crashing. When a value could be several types, a type switch switch v := x.(type) handles them all and binds v to the correct concrete type inside each case. It all works because an interface secretly carries its (type, value) pair.


Error Handling

13

The error Interface

intermediate go errors

In Go, an error is just a value. Not a special crash, not a thing you “throw” — a regular value that a function returns alongside its result. We check it, and we decide what to do.

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.”

This is called errors as values, and it’s one of the most distinctly Go things there is.

Why no exceptions?

Most languages (Java, Python, JS) use exceptions for errors. You throw, and somewhere far away a catch handles it. The problem? The error path is invisible. You can’t tell by reading a function whether it might blow up.

Go’s authors hated that. They wanted the error handling to be right there in the code, impossible to ignore.

So in Go, normal failures (file not found, network timeout, bad input) are not exceptions. They’re returned values you must deal with on the spot.

The error interface

Here’s the whole thing. The error type is just an interface with one method:

type error interface {
	Error() string // returns the error message as a string
}

That’s it. Anything with an Error() string method is an error. No magic.

The idiomatic pattern: if err != nil

By convention, functions that can fail return the error as their last return value. We check it immediately.

f, err := os.Open("config.yaml") // returns (*os.File, error)
if err != nil {
	// something went wrong — handle it and bail out
	return err // pass the problem up to whoever called us
}
defer f.Close() // only runs if we got here, meaning err was nil
// ... use f safely, we know it's valid now

Think of it like a bouncer at every door: check the error, and if it’s there, you don’t get to touch the result.

Creating errors

Two everyday tools.

errors.New — for a simple static message:

import "errors"

func withdraw(balance, amount int) (int, error) {
	if amount > balance {
		return 0, errors.New("insufficient funds") // plain message
	}
	return balance - amount, nil // nil means "no error, all good"
}

fmt.Errorf — when we want to drop variables into the message:

import "fmt"

func getUser(id int) error {
	// %d, %s etc. work just like Printf
	return fmt.Errorf("user %d not found", id)
}

Returning errors up the stack

We rarely handle an error where it happens. Usually we just hand it upward, and the top-level caller (like an HTTP handler) decides what to show the user. Each layer either handles the error or returns it.

func loadConfig() (*Config, error) {
	data, err := os.ReadFile("config.json")
	if err != nil {
		return nil, err // can't read? give up, pass it up
	}
	return parse(data) // parse also returns (*Config, error)
}

Sentinel errors

Sometimes we need to check for a specific, known error. Like “did we hit the end of the file?” For that we use a sentinel error — a predefined error value we compare against.

The classic example is io.EOF (“end of file”). When a reader is done, it returns exactly that value, and we check for it by identity:

import "io"

for {
	n, err := reader.Read(buf)
	if err == io.EOF { // the agreed-upon "we're done" signal
		break // not a real problem, just stop reading
	}
	if err != nil {
		return err // a real error this time
	}
	process(buf[:n])
}

A sentinel is just a package-level variable like var ErrNotFound = errors.New("not found") that callers compare against. (In modern Go we compare with errors.Is instead of == — more on that in the wrapping note.)

The “if err != nil everywhere” criticism — honestly

Yeah, it’s real. Go code is littered with if err != nil { return err }. Critics call it noisy and repetitive, and they’re not wrong — you’ll type it hundreds of times.

But here’s the trade-off Go makes on purpose:

  • Errors are visible. You can see every failure path by reading top to bottom. No hidden jumps.
  • Errors are hard to ignore. The compiler complains about unused variables, nudging us to actually check.
  • The control flow is dead simple. No invisible stack unwinding, no “where does this catch?” puzzles.

So the verbosity is the price for explicitness. Most Go devs grow to like it — the boilerplate is boring, but boring is predictable, and predictable is good in production.

Interview soundbite

In Go, errors are ordinary values returned as the last return value, and error is just an interface with a single Error() string method. We handle them inline with if err != nil, create them with errors.New or fmt.Errorf, and check known cases against sentinel values like io.EOF. The famous if err != nil boilerplate is the deliberate price for making every failure path explicit and impossible to silently ignore.


14

defer, panic & recover

intermediate go errors defer panic

These three keywords are Go’s tools for cleanup and for handling the truly exceptional. They sound scary but they’re simple once we see them in action.

defer

defer schedules a function call to run when the surrounding function exits — no matter how it exits (normal return, or even a panic).

In simple language: “do this later, right before I leave, whatever happens.”

Why do we love it? Cleanup. We open something, immediately defer closing it, and never worry about forgetting — even if the function returns from ten different spots.

func readFile(name string) error {
	f, err := os.Open(name)
	if err != nil {
		return err
	}
	defer f.Close() // guaranteed to run when readFile exits
	// ... do a bunch of stuff with f, return wherever we want ...
	return nil // f.Close() fires right after this
}

defer runs in LIFO order

If we defer multiple things, they run last-in-first-out — like a stack of plates. The last one we deferred runs first.

func main() {
	defer fmt.Println("1") // runs last
	defer fmt.Println("2") // runs second
	defer fmt.Println("3") // runs first
	fmt.Println("start")
}
// Output:
// start
// 3
// 2
// 1
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

The big gotcha: arguments are evaluated immediately

This trips up everyone. When we defer foo(x), Go evaluates x right now, at the defer line — not later when foo actually runs. The call is delayed, but the arguments are frozen at defer time.

func main() {
	i := 0
	defer fmt.Println("deferred:", i) // i is captured as 0 RIGHT HERE
	i = 99
	fmt.Println("now:", i)
}
// Output:
// now: 99
// deferred: 0   <-- not 99! it grabbed i when we deferred

If we actually want the latest value, wrap it in a closure (an inline function) so the read happens at exit time:

func main() {
	i := 0
	defer func() { fmt.Println("deferred:", i) }() // reads i when it runs
	i = 99
}
// Output:
// deferred: 99

The other classic use of defer is unlocking a mutex right after locking it, so we can never forget:

mu.Lock()
defer mu.Unlock() // unlock happens automatically on every exit path
// ... critical section ...

panic

panic stops the normal flow of a function. It unwinds the stack (running any deferred functions on the way up), and if nobody catches it, the whole program crashes and prints a stack trace.

Think of it like pulling the fire alarm. It’s loud, dramatic, and meant for emergencies — not everyday “file not found” stuff.

When should we use it? Only for truly unrecoverable situations — bugs that should never happen, broken invariants, impossible states. For normal failures, we return an error (see the errors note). Don’t use panic for control flow.

func mustGetEnv(key string) string {
	v := os.Getenv(key)
	if v == "" {
		// the app literally cannot run without this — fine to panic
		panic("missing required env var: " + key)
	}
	return v
}

recover

recover stops a panic mid-unwind and lets the program keep running. The catch: it only works inside a deferred function. Anywhere else, it does nothing.

In simple language: recover is the net we hang under the trapeze. It only counts if it’s set up (deferred) before the fall.

The classic real-world use is a server that shouldn’t die just because one request handler panicked:

func safeHandler(w http.ResponseWriter, r *http.Request) {
	defer func() {
		if rec := recover(); rec != nil { // catch the panic, if any
			log.Printf("recovered from panic: %v", rec)
			http.Error(w, "internal error", 500) // tell the client, stay alive
		}
	}()
	riskyWork(r) // if this panics, the deferred func above catches it
}
panic()
flow stops, stack starts unwinding
deferred funcs run
in LIFO order, on the way up
recover()
caught → program lives. not caught → crash

Interview soundbite

defer schedules a call to run when the function exits, in LIFO order, with its arguments evaluated immediately at the defer line — perfect for closing files and unlocking mutexes. panic unwinds the stack and is reserved for truly unrecoverable bugs, while recover — which only works inside a deferred function — catches a panic so something like a web server can log it and keep serving other requests instead of crashing.


15

Error Wrapping (Is / As)

intermediate go errors wrapping

Error wrapping means putting one error inside another, so we can add context (“while loading config: …”) without throwing away the original error underneath.

This landed in Go 1.13, and it fixed a real pain: before it, adding context meant mangling the error into a string and losing the ability to inspect it later.

The old, bad way: string concatenation

Before wrapping, people did this:

if err != nil {
	return fmt.Errorf("loading config failed: %s", err.Error()) // err becomes a string
}

The problem? Now the original error is just text. We’ve flattened it. We can never again ask “was this a file-not-found error?” or “what was the original error type?” — that information is gone forever.

The new way: wrap with %w

fmt.Errorf has a special verb, %w (for “wrap”). It adds our context message and keeps a link to the original error underneath.

import "fmt"

func loadConfig() error {
	err := os.ErrNotExist // pretend a file was missing
	if err != nil {
		// %w keeps err reachable inside the new error
		return fmt.Errorf("loading config: %w", err)
	}
	return nil
}

Think of it like nesting boxes. Each layer adds a label on the outside, but the original item is still in there, fully intact. We can open the boxes one at a time.

errors.Unwrap — peel one layer

errors.Unwrap pulls out the error that was wrapped, one level down. We rarely call it directly, but it’s what powers Is and As under the hood.

wrapped := fmt.Errorf("outer: %w", os.ErrNotExist)
inner := errors.Unwrap(wrapped) // returns os.ErrNotExist

errors.Is — “is this (anywhere in the chain) that specific error?”

When we want to check against a known sentinel error (a predefined error value like os.ErrNotExist or sql.ErrNoRows), we use errors.Is. It walks the whole chain, unwrapping as it goes, so it finds the target even if it’s buried five layers deep.

This is why we use errors.Is instead of == now — plain == only checks the outer error and misses wrapped ones.

import "errors"

err := loadConfig() // returns "loading config: file does not exist"
if errors.Is(err, os.ErrNotExist) { // checks the whole chain
	fmt.Println("the file was missing — let's create a default")
}

errors.As — “is there an error of this TYPE in the chain? give it to me”

Sometimes we don’t just want to know which error it is — we want the actual typed error so we can read its fields. That’s errors.As. It searches the chain for an error of a given type and, if found, fills in our variable so we can use it.

import "errors"

type ValidationError struct {
	Field string
}
func (e *ValidationError) Error() string { return "invalid field: " + e.Field }

// somewhere up the chain a *ValidationError got wrapped...
var ve *ValidationError
if errors.As(err, &ve) { // pass a POINTER to the target variable
	// found it — now we can read its fields
	fmt.Println("bad field was:", ve.Field)
}

The simple rule of thumb:

  • Use errors.Is to compare against a specific value (a sentinel).
  • Use errors.As to extract a specific type (so you can 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

Why wrapping beats string concatenation

  • We keep the original error — both its value and its type survive, so Is and As still work.
  • We get a readable trail — printing the final error shows the full context: "loading config: open file: file does not exist".
  • Callers stay decoupled — a top-level handler can check errors.Is(err, sql.ErrNoRows) without caring how many layers wrapped it on the way up.

One caution: wrapping with %w makes the inner error part of your public API — callers can now depend on it. If you don’t want that coupling, use %v instead of %w to include the message as plain text without exposing the wrapped value.

Interview soundbite

Since Go 1.13 we wrap errors with fmt.Errorf("...: %w", err), which adds context while preserving the original error’s value and type. We then inspect the chain with errors.Is to match a specific sentinel value and errors.As to extract a specific error type so we can read its fields. This beats string concatenation because the underlying error survives intact, so callers can still reason about what failed no matter how many layers wrapped it.


Concurrency

16

Goroutines

intermediate go concurrency

A goroutine is a function that runs concurrently with other code. We start one by slapping the word go in front of a function call. That’s it.

In simple language, a goroutine is like a super-cheap thread we can spin up with one keyword.

go doSomething() // runs doSomething() concurrently, doesn't wait

The line above kicks off doSomething() and immediately moves on. We don’t block, we don’t wait — the goroutine does its thing in the background.

Why goroutines are special

Normal OS threads are expensive. Each one grabs around 1MB of stack memory and the operating system has to schedule them. Spin up thousands and your machine starts sweating.

Goroutines are different. Each one starts with only about 2KB of stack, and that stack grows and shrinks as needed. So we can happily run tens of thousands of goroutines on a normal machine.

Think of it like this: an OS thread is renting a big apartment, a goroutine is crashing on a friend’s couch. Way cheaper to have a lot of them.

The other trick is that goroutines don’t map one-to-one onto OS threads. The Go runtime scheduler multiplexes many goroutines onto a small number of OS threads. We’ll cover the scheduler in detail elsewhere — for now, just know the runtime is the smart middleman juggling everything.

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

A main that exits kills everything

Here’s a gotcha that bites everyone once. When main() returns, the whole program exits — and any goroutines still running get killed instantly. No mercy.

package main

import "fmt"

func main() {
	go fmt.Println("hello from goroutine") // might never print!
	// main returns immediately, program dies before the goroutine runs
}

This program usually prints nothing. The goroutine never gets a chance to run because main exits first. We need to synchronize — wait for goroutines to finish using a sync.WaitGroup or a channel. (We cover both soon.)

The classic loop variable gotcha

This one shows up in interviews all the time. Before Go 1.22, the loop variable was shared across iterations.

// Go 1.21 and earlier — BUGGY
for _, v := range []string{"a", "b", "c"} {
	go func() {
		fmt.Println(v) // all three goroutines often print "c"
	}()
}

Why? All the goroutines closed over the same v variable. By the time they ran, the loop had finished and v held the last value. Classic.

The old fix was to pass it in or shadow it:

for _, v := range []string{"a", "b", "c"} {
	v := v // make a fresh copy per iteration
	go func() { fmt.Println(v) }()
}

Good news: Go 1.22 changed loop semantics so each iteration gets a fresh variable. So in modern Go the buggy version works fine. But interviewers love asking about it, so know both the bug and the fix.

Interview soundbite

A goroutine is a lightweight, runtime-managed thread started with the go keyword — it begins with about a 2KB growable stack, so we can run tens of thousands of them. The Go scheduler multiplexes them onto a few OS threads. Two classic gotchas: main exiting kills all goroutines (so we must synchronize), and the pre-Go-1.22 loop variable capture bug where goroutines shared one variable.


17

Channels

intermediate go concurrency

A channel is a typed pipe that goroutines use to send and receive values. One goroutine puts something in, another takes it out.

There’s a famous Go saying that sums up the whole philosophy:

“Don’t communicate by sharing memory; share memory by communicating.”

In simple language: instead of multiple goroutines poking at the same variable behind locks, we pass data through a channel. Whoever holds the value owns it. No locks, no races.

Making and using a channel

We create a channel with make, send with ch <- x, and receive with <-ch.

ch := make(chan int) // a channel that carries ints

go func() {
	ch <- 42 // send 42 into the channel
}()

value := <-ch // receive from the channel, blocks until something arrives
fmt.Println(value) // 42

The arrow always points in the direction the data flows. ch <- x means “x goes into ch”. <-ch means “pull a value out of ch”.

Unbuffered vs buffered

This is the part interviewers dig into.

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 — both sides meet at the same moment.

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 the sender can drop a few values and walk away without waiting.

Unbuffered — handshake
sender → ⟷ → receiver
both block until they meet
Buffered (cap 3)
sender [ _ _ _ ] receiver
sender only blocks when buffer is full
ch := make(chan int, 2) // buffer holds 2
ch <- 1 // doesn't block
ch <- 2 // doesn't block
// ch <- 3 // would block here — buffer is full
fmt.Println(<-ch, <-ch) // 1 2

Closing and ranging

When we’re done sending, we close the channel. Receivers can then detect it.

ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch) // no more sends allowed after this

for v := range ch { // range keeps receiving until the channel is closed and drained
	fmt.Println(v) // 1, then 2
}

The two-value receive tells us if a channel is closed:

v, ok := <-ch // ok is false once the channel is closed and empty

The dangerous bits (interview favorites)

These three rules trip people up constantly:

  • Sending on a closed channel panics. Only the sender should close, never the receiver.
  • Receiving from a closed channel returns the zero value immediately (with ok == false). No panic.
  • A nil channel blocks forever. Sending or receiving on a nil channel never proceeds. Sometimes useful in select to disable a case, but usually a bug.
var ch chan int // nil channel
// <-ch // blocks forever — deadlock

Interview soundbite

A channel is a typed pipe for passing values between goroutines, embodying Go’s motto “share memory by communicating.” Unbuffered channels are a synchronous handshake — send blocks until a receiver shows up; buffered channels only block when full or empty. Remember the gotchas: closing then sending panics, only the sender closes, receiving from a closed channel gives the zero value, and a nil channel blocks forever.


18

The select Statement

intermediate go concurrency

select lets a goroutine wait on multiple channel operations at the same time. It blocks until one of them is ready, then runs that case.

In simple language, think of it like a switch statement — but instead of comparing values, each case is a channel send or receive, and it picks whichever one is ready first.

select {
case msg := <-ch1:
	fmt.Println("got from ch1:", msg)
case msg := <-ch2:
	fmt.Println("got from ch2:", msg)
}

This sits and waits. The moment either ch1 or ch2 has something, that case runs and the rest are ignored.

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

What if several are ready at once?

Go picks one at random. This is deliberate — it stops any single channel from starving the others. We can’t rely on ordering, so never assume the first case wins.

Non-blocking with default

Add a default case and select stops blocking. If nothing is ready right now, it runs default immediately.

select {
case msg := <-ch:
	fmt.Println("received:", msg)
default:
	fmt.Println("nothing ready, moving on") // runs instantly if ch is empty
}

This is the classic “try to receive but don’t wait around” pattern.

The timeout pattern

This is the one interviewers love. We pair a real channel against time.After, which returns a channel that fires after a duration.

select {
case result := <-ch:
	fmt.Println("got result:", result)
case <-time.After(2 * time.Second):
	fmt.Println("timed out!") // ch took too long
}

If ch doesn’t deliver within 2 seconds, the timeout case wins. Dead simple way to avoid waiting forever.

The done-channel pattern

We can tell a goroutine to stop by closing a done channel. Since a closed channel always returns immediately, the <-done case fires the moment we close it.

func worker(jobs <-chan int, done <-chan struct{}) {
	for {
		select {
		case j := <-jobs:
			fmt.Println("working on", j)
		case <-done: // closed channel unblocks instantly
			fmt.Println("stopping")
			return
		}
	}
}

We close done from outside and the worker exits cleanly. (In real code we’d usually reach for context here — covered next.)

Interview soundbite

select is like a switch for channels — it blocks until one of several channel operations is ready, and if several are ready it picks one at random to prevent starvation. Add a default case to make it non-blocking. The two patterns to know cold are the timeout (case <-time.After(...)) and the done-channel for cancellation.


19

The sync Package

intermediate go concurrency

The sync package gives us the classic low-level concurrency tools — locks, wait groups, one-time initializers. Channels are great, but sometimes we just need to guard a shared variable or wait for a bunch of goroutines to finish. That’s what sync is for.

Mutex — guarding shared state

A sync.Mutex is a lock. Only one goroutine can hold it at a time. We Lock() before touching shared data and Unlock() after.

In simple language, think of it like a single bathroom key. Whoever has the key gets in; everyone else waits in line.

var (
	mu      sync.Mutex
	counter int
)

func increment() {
	mu.Lock()         // grab the key
	defer mu.Unlock() // always release it, even if we panic
	counter++         // safe: only one goroutine here at a time
}

The defer mu.Unlock() is the idiomatic move — it guarantees we release the lock no matter how the function exits.

RWMutex — many readers, one writer

A sync.RWMutex is smarter when reads vastly outnumber writes. Many goroutines can hold the read lock at once, but a write lock is exclusive.

var rw sync.RWMutex
rw.RLock()        // multiple readers allowed together
// ... read shared data ...
rw.RUnlock()

rw.Lock()         // exclusive — blocks all readers and writers
// ... write shared data ...
rw.Unlock()

Use it when reads are frequent and writes are rare, like a config that’s read constantly but updated occasionally.

WaitGroup — wait for goroutines to finish

This is the canonical pattern, and it shows up in basically every Go interview. A sync.WaitGroup counts running goroutines and lets us block until they’re all done.

The recipe is Add, Done, Wait:

var wg sync.WaitGroup

for i := 1; i <= 3; i++ {
	wg.Add(1) // tell the group "one more goroutine"
	go func(id int) {
		defer wg.Done() // signal "this one finished"
		fmt.Println("worker", id, "done")
	}(i)
}

wg.Wait() // block here until the counter hits zero
fmt.Println("all workers finished")

Three rules to remember: call Add before launching the goroutine (not inside it), always defer wg.Done(), and Wait in the goroutine that needs everything finished.

Once — run something exactly once

sync.Once guarantees a function runs only one time, even if many goroutines call it. Perfect for lazy initialization (setting up a connection, loading config).

var once sync.Once

func setup() {
	once.Do(func() {
		fmt.Println("initializing — runs only once") // safe under concurrency
	})
}

Call setup() from a hundred goroutines and the init block still runs exactly once.

sync.Map — a concurrent map (sometimes)

A regular Go map is not safe for concurrent writes — it’ll panic. sync.Map is a ready-made concurrent map, but it’s specialized. It shines in two cases: keys are written once and read many times, or different goroutines touch disjoint keys.

For the common case, a plain map guarded by a sync.Mutex is usually clearer and faster. Reach for sync.Map only when profiling says so.

atomic — the lightweight teaser

For simple counters and flags, sync/atomic does lock-free updates that are faster than a mutex.

var n int64
atomic.AddInt64(&n, 1)        // atomic increment, no lock
v := atomic.LoadInt64(&n)     // atomic read

Great for a single counter; not a replacement for a mutex when you need to guard multiple fields together.

Mutex vs channels — when to use which

The rule of thumb: use a mutex to protect shared state (a counter, a cache, a struct field). Use a channel to transfer ownership of data or coordinate goroutines (passing work, signaling done). If you’re sharing memory, lock it. If you’re communicating, channel it.

Interview soundbite

The sync package is the lock-based toolkit: Mutex/RWMutex guard shared state, WaitGroup waits for goroutines via the Add/Done/Wait pattern, and Once runs init exactly once. The rule of thumb: use a mutex to protect shared memory, use a channel to communicate and transfer ownership. For simple counters, sync/atomic is faster than a mutex.


20

The context Package

intermediate go concurrency

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, a context is like a “stop” signal we hand to every function involved in a request. When the user closes the browser tab or a timeout hits, we flip the signal and everyone downstream knows to give up and clean up.

Why we need it

Imagine an HTTP request that triggers a database query, which triggers another service call. If the client disconnects, all that work is wasted. Without context, those goroutines keep grinding away. With context, the cancellation flows down and they all stop. It prevents leaked goroutines and wasted work.

Creating contexts

Every context tree starts from a root. context.Background() is the empty root we use at the top of main, in tests, and at request entry points.

From there, we derive child contexts that add behavior:

ctx, cancel := context.WithCancel(context.Background())
defer cancel() // always call cancel to free resources

ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel2() // ctx2 auto-cancels after 2 seconds

ctx3, cancel3 := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
defer cancel3() // ctx3 cancels at a specific wall-clock time
  • WithCancel — gives us a cancel() function to stop it manually.
  • WithTimeout — auto-cancels after a duration.
  • WithDeadline — auto-cancels at a specific time.

Always defer cancel(). Even if the context finishes on its own, calling cancel releases resources. Forgetting it leaks.

Listening for cancellation

Two methods do the work. ctx.Done() returns a channel that closes when the context is cancelled. ctx.Err() tells us why (context.Canceled or context.DeadlineExceeded).

select {
case <-ctx.Done():
	fmt.Println("cancelled:", ctx.Err()) // gives the reason
	return
case result := <-work:
	fmt.Println("got:", result)
}

Honoring context in an HTTP handler

Every *http.Request already carries a context tied to the connection. If the client disconnects, that context cancels automatically. We just have to listen.

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context() // cancels if the client goes away

	select {
	case <-time.After(3 * time.Second): // pretend this is slow work
		fmt.Fprintln(w, "done!")
	case <-ctx.Done():
		// client disconnected or timed out — bail out, don't waste work
		http.Error(w, ctx.Err().Error(), http.StatusRequestTimeout)
	}
}

If the user closes the tab after one second, ctx.Done() fires and we stop instead of finishing the full three seconds of work.

The convention and the rules

Context has a few firm conventions that 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. Pass it explicitly through function calls. A context is per-request and short-lived; a struct usually outlives the request.
  • Don’t use context.Value for passing regular parameters. It’s only for request-scoped data that crosses API boundaries — things like a request ID or auth token — not for function arguments. Values are untyped (interface{}), so abusing them hides your function’s real inputs.
ctx := context.WithValue(context.Background(), "requestID", "abc123")
id := ctx.Value("requestID") // request-scoped metadata, NOT business params

Interview soundbite

context propagates cancellation, deadlines, and request-scoped values down a call chain. We start from context.Background(), derive children with WithCancel/WithTimeout/WithDeadline, always defer cancel(), and listen on ctx.Done() checking ctx.Err() for the reason. Conventions: ctx is the first arg, never store it in a struct, and Value is only for request-scoped metadata — never for passing normal parameters.


21

Concurrency Patterns

advanced go concurrency

Once we know goroutines, channels, select, and sync, the next step is combining them into patterns. These are the building blocks that show up in real backend code — and in interviews.

Worker pool

A worker pool spins up a fixed number of goroutines that all pull from the same jobs channel and push to a results channel. We control concurrency by choosing how many workers to start.

In simple language, think of it like a checkout line with N cashiers. Customers (jobs) line up, and whichever cashier is free grabs the next one.

jobs chan
worker 1 worker 2 worker 3
results chan
func worker(jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
	defer wg.Done()
	for j := range jobs { // pulls until jobs is closed
		results <- j * 2 // do the work, send the result
	}
}

func main() {
	jobs := make(chan int, 100)
	results := make(chan int, 100)
	var wg sync.WaitGroup

	for w := 1; w <= 3; w++ { // start 3 workers
		wg.Add(1)
		go worker(jobs, results, &wg)
	}

	for j := 1; j <= 9; j++ {
		jobs <- j
	}
	close(jobs) // tell workers no more jobs are coming

	go func() { wg.Wait(); close(results) }() // close results once all workers done

	for r := range results {
		fmt.Println(r)
	}
}

Note the trick at the end: we close(jobs) so the workers’ range loops finish, then close results in a separate goroutine once the WaitGroup confirms everyone’s done. That lets the final range results terminate cleanly.

Fan-out / fan-in

Fan-out means several goroutines read from the same channel to spread work across cores. Fan-in means we merge several channels back into one. The worker pool above is already fan-out; fan-in is the merge step.

Fan-out
src
w1 w2
Fan-in
c1 c2
merged
func merge(cs ...<-chan int) <-chan int {
	out := make(chan int)
	var wg sync.WaitGroup
	for _, c := range cs {
		wg.Add(1)
		go func(ch <-chan int) {
			defer wg.Done()
			for v := range ch {
				out <- v // funnel every input channel into out
			}
		}(c)
	}
	go func() { wg.Wait(); close(out) }() // close out when all inputs drained
	return out
}

Pipeline

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.

func gen(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		for _, n := range nums {
			out <- n
		}
		close(out)
	}()
	return out
}

func square(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		for n := range in {
			out <- n * n // transform each value
		}
		close(out)
	}()
	return out
}

// usage: for v := range square(gen(2, 3, 4)) { ... } // 4, 9, 16

Each stage takes a receive-only input channel and returns a receive-only output channel. Stages run concurrently, and closing flows downstream.

Rate limiting with a ticker

A time.Ticker fires on a channel at a fixed interval. We read from it before each action to throttle ourselves.

limiter := time.NewTicker(200 * time.Millisecond) // 5 ops/sec
defer limiter.Stop()

for req := range requests {
	<-limiter.C // wait for the next tick before proceeding
	go handle(req)
}

Done-channel cancellation

When we want to stop a pipeline early, we pass a done channel into each stage and select on it. Closing done signals everyone to quit. (In real code, context usually plays this role — same idea, standardized.)

func square(done <-chan struct{}, in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for n := range in {
			select {
			case out <- n * n:
			case <-done: // bail out early if cancelled
				return
			}
		}
	}()
	return out
}

Interview soundbite

The core Go concurrency patterns are: worker pools (N goroutines draining a shared jobs channel, coordinated with a WaitGroup), fan-out/fan-in (spread work across goroutines then merge results), pipelines (chained stages connected by channels where closing flows downstream), rate limiting with a ticker, and done-channel cancellation. The recurring discipline is: close channels from the sender side and propagate cancellation downstream so nothing leaks.


22

Data Races & the Race Detector

advanced go concurrency

A data race happens when two or more goroutines access the same memory at the same time, at least one of them is writing, and there’s no synchronization between them.

All three conditions have to be true. Two goroutines only reading? Fine. One goroutine? Fine. Properly locked? Fine. But concurrent unsynchronized access with a write in the mix — that’s a race.

Why they’re so nasty

Data races are the worst kind of bug because they’re nondeterministic. The program might work perfectly 999 times and corrupt data the 1000th. It depends 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 Google Doc cell at the exact same instant with no locking — whoever’s write lands last wins, and you can’t predict who that’ll be. The result is corrupted state, lost updates, or a crash, and it almost never reproduces when you’re watching.

A racy example

Here’s the classic. Many goroutines incrementing one counter with no lock.

func main() {
	counter := 0
	var wg sync.WaitGroup

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter++ // RACE: read-modify-write with no synchronization
		}()
	}

	wg.Wait()
	fmt.Println(counter) // almost never 1000
}

counter++ looks atomic but it’s actually three steps: read, add one, write back. Two goroutines can read the same value, both add one, and both write it back — so two increments become one. We lose updates.

The race detector

This is the headline tool, and interviewers love that Go ships it built in. Add the -race flag and Go instruments your program to detect races at runtime.

go run -race main.go    # run with detection
go test -race ./...     # test suite with detection
go build -race          # build an instrumented binary

When it catches one, it prints exactly which goroutines touched which memory and where, including stack traces. It only finds races on code paths that actually execute, so run it against your tests and real workloads. It’s slower and uses more memory, so it’s a dev/CI tool, not for production.

Fixing it — option 1: mutex

Guard the shared variable with a lock so only one goroutine touches it at a time.

var (
	mu      sync.Mutex
	counter int
)

func inc() {
	mu.Lock()
	defer mu.Unlock()
	counter++ // now safe — only one goroutine in here at a time
}

For a single counter, atomic.AddInt64 is an even cheaper fix.

Fixing it — option 2: channel

Instead of sharing the counter, give one goroutine ownership and have others send it work. Share memory by communicating.

counts := make(chan int)
go func() {
	total := 0
	for range counts { // only THIS goroutine touches total
		total++
	}
}()
// other goroutines just do: counts <- 1

Only one goroutine ever reads or writes total, so there’s nothing to race.

The loop-variable classic

We mentioned this in the goroutines note, and it’s a genuine data race in older Go. Before Go 1.22, every goroutine captured the same loop variable, and the loop kept writing to it while the goroutines read it — concurrent unsynchronized access.

// Go 1.21 and earlier — racy
for i := 0; i < 3; i++ {
	go func() {
		fmt.Println(i) // reads i while the loop writes it
	}()
}

Run that with -race on old Go and it lights up. The fix is to copy the variable per iteration (i := i) or pass it as an argument. Go 1.22 fixed the scoping so each iteration gets its own variable.

Interview soundbite

A data race is concurrent access to the same memory where at least one access is a write and there’s no synchronization. They’re brutal because they’re nondeterministic and rarely reproduce. Go’s built-in race detector (go run -race, go test -race) finds them on executed code paths with full stack traces. Fix them by guarding state with a mutex/atomic, or by giving one goroutine ownership and communicating via channels.


Runtime & Memory

23

Garbage Collection

advanced go runtime

Garbage collection (GC) is the runtime automatically finding memory we’re no longer using and freeing it. We never call free() in Go — the collector does it for us.

In simple language: we keep allocating stuff, and a little janitor runs in the background, figures out what’s trash, and cleans it up.

Why Go’s GC is special

Most GCs make you pick: fast cleanup (throughput) OR short pauses (latency). Go picked latency.

The whole design goal is: don’t freeze the program for long. Pauses are tiny — usually sub-millisecond. That’s perfect for servers handling live requests, where a 100ms freeze means angry users.

The trade-off? Go’s GC does a bit more total work and uses more CPU than a throughput-focused collector. Go’s authors decided short, predictable pauses are worth it.

The three big design choices

Go’s collector is:

  • Concurrent — it runs at the same time as our program, on separate goroutines. It barely stops the world.
  • Non-generational — it doesn’t sort objects into “young” and “old” buckets like Java does. It just scans everything reachable.
  • Non-compacting — it never moves objects around to defragment memory. Once a thing has an address, it stays put. (This is partly why Go pointers are safe to hold.)

The tri-color mark-and-sweep — explained simply

This is the heart of it. The collector splits all objects into three colors:

  • White = “probably garbage, haven’t looked at it yet.”
  • Grey = “reachable, but I still need to check what it points to.”
  • Black = “reachable, and I’ve already checked everything it points to. Done.”

In simple language: white is the suspect pile, grey is the to-do list, black is the verified-safe pile.

The algorithm:

  1. Everything starts white.
  2. Mark the roots (global variables, things on goroutine stacks) grey.
  3. Pick a grey object, look at everything it points to, paint those grey, then paint the object itself black.
  4. Repeat until no grey objects remain.
  5. Whatever is still white is unreachable → that’s garbage → sweep (free) it.
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

How does it scan while the program is still running?

Here’s the tricky part. If the GC is marking objects while our code keeps changing pointers, our code could sneak a new pointer from a black (done) object to a white (about-to-be-deleted) object. The collector would never see it and would wrongly free live memory. Disaster.

The fix is write barriers.

In simple language: a write barrier is a tiny piece of code the compiler injects around pointer writes. While the GC is running, whenever we change a pointer, the barrier whispers to the collector “hey, note this new connection.” This keeps the colors honest so nothing reachable gets thrown away.

There are two brief stop-the-world (STW) pauses — to turn the write barrier on at the start and to finish marking at the end — but these are the sub-millisecond pauses, not a full freeze.

Tuning knob #1: GOGC

GOGC controls how often the GC runs. The default is 100.

It means: let the heap grow to 100% bigger than the live set before collecting again. So if 50MB is live, GC triggers around 100MB.

  • Higher GOGC (e.g. 200) = GC runs less often = more memory used, less CPU spent collecting.
  • Lower GOGC (e.g. 50) = GC runs more often = less memory, more CPU.
GOGC=200 ./myserver   # trade memory for fewer GC cycles (lower CPU)
GOGC=off ./myserver    # disable GC entirely (rare — only short-lived tools)

Tuning knob #2: GOMEMLIMIT (Go 1.19+)

GOGC alone has a flaw: if your live memory suddenly spikes, the heap target spikes too, and you can run out of RAM and get OOM-killed.

GOMEMLIMIT is a soft memory limit. We tell Go “try hard not to exceed this much memory total.” As we approach the limit, the GC runs more aggressively to stay under it.

GOMEMLIMIT=512MiB ./myserver   # soft cap; GC works harder near 512MiB

In simple language: GOGC is the normal pace, GOMEMLIMIT is the emergency brake. The common combo for containers is to set GOMEMLIMIT near your container memory and let GOGC handle the normal rhythm. It’s “soft” because Go will blow past it rather than crash if memory is genuinely needed.

Interview soundbite

Go uses a concurrent, non-generational, non-compacting tri-color mark-and-sweep collector tuned for low latency — pauses are typically sub-millisecond because marking runs alongside our program, guarded by write barriers so live objects never get freed mid-scan. We tune it with GOGC (how much the heap grows before collecting, default 100) and the soft GOMEMLIMIT (Go 1.19+) to keep memory under a cap, which together prevent OOM kills in containers.


24

Every value our program creates lives in one of two places: the stack or the heap. Where it lives changes how fast it is 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 that the garbage collector has to clean up later.

Stack vs heap — the difference

  • Stack — super fast. Allocating is just bumping a pointer. When the function returns, everything it put on the stack vanishes instantly. No GC involved. Each goroutine gets its own.
  • Heap — slower. The runtime has to find space, and later the GC must track and free it. Anything that needs to outlive its function goes here.

So stack = cheap and auto-cleaned. Heap = costs GC effort. We want values on the stack when 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? The compiler — at compile time

In languages like C, we decide (malloc = heap). In Go, the compiler decides for us, before the program ever runs. This decision-making is called escape analysis.

In simple language: the compiler asks one question for every value — “does anything need this after the function returns?” If no, keep it on the cheap stack. If yes, it escapes to the heap.

It’s “escape” because the value escapes the lifetime of the function that created it.

What causes a value to escape?

A few common patterns push values onto the heap:

  • Returning a pointer to a local variable — the local must live on after we return, so it can’t sit on the dying stack frame.
  • Storing in an interface (interface boxing) — putting a concrete value into an interface{} often forces it to the heap.
  • Closures capturing a variable — if a function literal captures a variable and outlives the parent, that variable escapes.
  • Value too big for the stack — huge arrays/slices go to the heap.
  • Sending a pointer to a channel — the runtime can’t prove when it’s done, so it escapes.

A quick escape example

// This pointer ESCAPES — u must survive after newUser returns.
func newUser(name string) *User {
	u := User{Name: name} // looks local...
	return &u             // ...but we hand out its address, so it goes to the heap
}

// This does NOT escape — x is used and discarded inside the function.
func sum(a, b int) int {
	x := a + b // lives and dies on the stack, never leaves
	return x   // we return a copy of the value, not its address
}

The first one looks like a classic C bug (returning a pointer to a local). In Go it’s totally safe — the compiler sees the escape and quietly moves u to the heap so the pointer stays valid.

See the decisions yourself

We don’t have to guess. The compiler will tell us with -gcflags="-m":

go build -gcflags="-m" ./...
# example output:
# ./main.go:5:2: moved to heap: u
# ./main.go:11:2: x does not escape

In simple language: -m prints the compiler’s escape-analysis reasoning. “moved to heap” = it escaped. “does not escape” = it stayed on the stack. This is gold for performance work — fewer heap escapes means less GC pressure and faster code.

Bonus: stacks grow

Each goroutine starts with a tiny stack (around 8KB). If it needs more, the runtime allocates a bigger stack and copies the frames over. That’s why we can spin up millions of goroutines cheaply — they don’t pre-reserve big stacks like OS threads do.

Interview soundbite

Go puts values on a fast, auto-freed per-goroutine stack when it can, and on the GC-managed heap when a value must outlive its function — the compiler decides this at compile time via escape analysis. Things like returning a pointer to a local, interface boxing, captured closures, and oversized values cause escapes, and we can inspect every decision with go build -gcflags="-m". Keeping values on the stack reduces GC pressure, which is a key performance lever.


25

The Scheduler (GMP Model)

advanced go runtime

The Go scheduler is the part of the runtime that decides which goroutine runs on which OS thread, and when. It’s what lets us launch a million goroutines without melting our laptop.

In simple language: OS threads are expensive and few. Goroutines are cheap and many. The scheduler’s job is to squeeze all those goroutines through the small number of threads efficiently.

The three players: G, M, P

The model is named after three things:

  • G — Goroutine. The thing we create with go func(). It’s a lightweight task with its own tiny stack. Millions can exist.
  • M — Machine (OS thread). A real operating-system thread. The only thing that can actually execute code on a CPU. These are limited and pricey.
  • P — Processor (a logical context). A scheduling “slot.” A P holds a local run queue of goroutines waiting to run. The number of Ps equals GOMAXPROCS (default = number of CPU cores).

The key rule: to run a goroutine (G), an OS thread (M) must hold a processor (P). No P, no running.

In simple language: think of a P as a desk, an M as a worker, and Gs as a stack of tasks on the desk. A worker needs a desk to do tasks. The number of desks (Ps) is fixed at your CPU count, so you only ever do that many tasks truly 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

Work stealing — keeping CPUs busy

Each P has its own local queue, which is fast (no locking with others). But what if one P empties its queue while another is swamped?

The idle P steals work. It grabs roughly half the goroutines from a busy P’s queue (or pulls from the shared global queue).

In simple language: a worker who finishes early walks over and takes half the tasks off a busier desk, so no CPU sits idle while work is waiting. This is work stealing, and it’s why Go balances load so well automatically.

The clever part: blocking syscalls

Here’s where the GMP model really shines. Say a goroutine makes a blocking syscall — like reading a file. The OS thread (M) running it is now stuck, frozen, waiting on the kernel.

If that froze the whole P, all the other goroutines in its queue would be stuck too. Bad.

So the runtime does a handoff: it detaches the P from the blocked M and hands it to another M (a fresh or parked thread). That new M picks up the P and keeps running the queued goroutines.

In simple language: if a worker gets stuck on a phone call (the syscall), we don’t let the desk sit idle. We grab another worker, hand them the desk, and they keep plowing through the tasks. When the original call finishes, that worker rejoins later.

This is why thousands of goroutines doing blocking I/O don’t grind your program to a halt.

Cooperative + preemptive scheduling

Originally Go was purely cooperative — a goroutine only gave up the CPU at “safe points” like function calls, channel ops, or select. Problem: a tight loop with no function calls could hog a P forever and starve everyone.

Since Go 1.14, the scheduler adds asynchronous preemption. The runtime can interrupt a long-running goroutine (using a signal) and force it to yield, even mid-loop.

In simple language: before 1.14, goroutines had to volunteer to step aside. Now the runtime can tap one on the shoulder and say “your turn’s up.” This prevents one greedy goroutine from freezing everything (and also lets the GC pause goroutines reliably).

Why goroutines are so cheap

// Spinning up 100,000 goroutines is totally normal in Go.
for i := 0; i < 100000; i++ {
	go func(n int) {
		// each one starts with just ~8KB of stack
		doSomething(n)
	}(i)
}

Goroutines are cheap because:

  • Tiny stacks (~8KB) that grow on demand — vs ~1–2MB fixed per OS thread.
  • Scheduled in user space by the Go runtime — switching between goroutines doesn’t need a slow kernel context switch.
  • Multiplexed — millions of Gs ride on just GOMAXPROCS threads.

In simple language: creating an OS thread is like hiring a full-time employee. Creating a goroutine is like writing a sticky note. That’s the difference in cost.

Interview soundbite

Go’s scheduler uses the GMP model: G is a goroutine, M is an OS thread, and P is a logical processor (count = GOMAXPROCS) that holds a local run queue — and an M needs a P to run any goroutine. Idle Ps do work stealing to balance load, and when a goroutine makes a blocking syscall the runtime hands its P off to another M so the other goroutines keep running. Scheduling is mostly cooperative but, since Go 1.14, also asynchronously preemptive, and because goroutines have tiny growable stacks and switch in user space, we can run millions of them cheaply.


Standard Library & Web

26

net/http

intermediate go stdlib

net/http is Go’s standard library package for building HTTP servers and clients. No framework needed — it ships with the language.

In simple language, we can spin up a production-grade web server with about three lines of code. That’s why Go is loved for backend work.

A server in three lines

The fastest way to get going is http.HandleFunc (register a handler for a path) plus http.ListenAndServe (start listening).

package main

import "net/http"

func main() {
	// register a function to handle requests to "/hello"
	http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("Hello!")) // write the response body
	})
	http.ListenAndServe(":8080", nil) // start server on port 8080
}

The nil we pass to ListenAndServe means “use the default router.” More on that below.

The two arguments every handler gets

Every handler receives w http.ResponseWriter and r *http.Request.

  • w is where we write the response — body, status code, headers.
  • r is the incoming request — we read the method, URL, headers, and body from it.
func handler(w http.ResponseWriter, r *http.Request) {
	name := r.URL.Query().Get("name") // read ?name=... from the URL
	w.Header().Set("Content-Type", "text/plain") // set a response header
	w.WriteHeader(http.StatusOK)                  // set status 200
	w.Write([]byte("Hi " + name))                 // write the body
}

One gotcha: we must call w.WriteHeader (and set headers) before writing the body. Once we write the body, the status and headers are locked in.

The http.Handler interface

Under the hood everything is built on one tiny interface:

type Handler interface {
	ServeHTTP(w http.ResponseWriter, r *http.Request)
}

Anything with a ServeHTTP method is a handler. http.HandleFunc is just a convenience — it wraps our plain function into an http.HandlerFunc, which is a type that satisfies this interface. So “a handler function” and “a Handler” are basically the same thing wearing different hats.

ServeMux — the router

http.ServeMux is Go’s built-in request multiplexer (a fancy word for router). It matches the request path to the right handler. The “default router” we passed nil for earlier is just a global ServeMux.

The big news: Go 1.22 upgraded ServeMux to support method and path-variable patterns. Before, it only matched plain prefixes — now it does proper routing.

mux := http.NewServeMux()

// match only GET requests to /users/{id}, with a path variable
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id") // pull the {id} out of the path
	w.Write([]byte("user " + id))
})

http.ListenAndServe(":8080", mux) // pass our mux instead of nil

Before 1.22 we needed a third-party router (gorilla/mux, chi) for this. Now the stdlib covers the common cases.

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

Middleware — just handlers wrapping handlers

Go has no special “middleware” concept. Middleware is simply a function that takes a handler and returns a new handler that does extra work before or after calling the original.

In simple language, it’s an onion — each layer wraps the next.

// logging middleware: wraps any handler, logs the method + path, then calls it
func logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		log.Printf("%s %s", r.Method, r.URL.Path) // do work before
		next.ServeHTTP(w, r)                       // call the wrapped handler
	})
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("home"))
	})
	http.ListenAndServe(":8080", logging(mux)) // wrap the whole mux
}

We can stack them: auth(logging(mux)). Each one returns a Handler, so they chain cleanly.

The client side

net/http is also a client. http.Get fires off a GET request and gives us a response.

resp, err := http.Get("https://api.example.com/data")
if err != nil {
	log.Fatal(err)
}
defer resp.Body.Close() // ALWAYS close the body, or we leak connections

body, _ := io.ReadAll(resp.Body) // read the whole response body
fmt.Println(string(body))

The one rule everyone forgets: always defer resp.Body.Close(). Skip it and we leak network connections.

Interview soundbite

net/http gives us a full HTTP server and client in the standard library, all built on the one-method Handler interface (ServeHTTP). The ServeMux router got a big upgrade in Go 1.22 with method-and-path patterns like "GET /users/{id}". Middleware isn’t a special feature — it’s just a function that wraps a handler and returns a new one, so we chain them like an onion.

References


27

JSON Encoding/Decoding

intermediate go stdlib

encoding/json is the standard library package for converting Go values to JSON and back. Encoding is called marshaling, decoding is unmarshaling.

In simple language, marshal = Go struct → JSON bytes, unmarshal = JSON bytes → Go struct.

Marshal and Unmarshal

These two functions do the bulk of the work.

type User struct {
	Name string
	Age  int
}

u := User{Name: "Manish", Age: 28}
data, _ := json.Marshal(u)   // struct → JSON: {"Name":"Manish","Age":28}
fmt.Println(string(data))

var back User
json.Unmarshal(data, &back)  // JSON → struct (note the & — needs a pointer)
fmt.Println(back.Name)       // Manish

Unmarshal needs a pointer (&back) because it has to write into our variable. Forget the & and nothing happens.

Struct tags control the JSON

By default Go uses the field name as-is. That gives us "Name" with a capital N — ugly for JSON. We fix that with struct tags, the little strings in backticks after each field.

type User struct {
	Name  string `json:"name"`            // becomes "name"
	Email string `json:"email,omitempty"` // omitted entirely if empty
	pass  string `json:"-"`               // "-" means never include
}
  • json:"name" renames the field in the JSON.
  • ,omitempty drops the field if it’s the zero value (empty string, 0, nil).
  • json:"-" excludes it completely.
Go struct
Name string `json:"name"`
Email string `json:"email,omitempty"`
JSON
{
"name": "Manish",
// email dropped if empty
}

Exported fields only — the #1 gotcha

This catches everyone. encoding/json can only see exported fields — the ones starting with a capital letter.

type Config struct {
	Port int    // exported → shows up in JSON
	host string // lowercase → INVISIBLE to json, silently skipped
}

If our JSON output is mysteriously missing a field, 90% of the time it’s because the field is lowercase. No error, it just quietly disappears. The reason is that json lives in another package and Go’s visibility rules block it from touching unexported fields.

Structs vs map[string]interface{}

We have two ways to decode JSON:

Into a struct — when we know the shape ahead of time. Type-safe, clean.

var u User
json.Unmarshal(data, &u) // we get u.Name, u.Age — typed

Into a map[string]interface{} — when the shape is dynamic or unknown.

var m map[string]interface{}
json.Unmarshal(data, &m)
name := m["name"].(string) // need a type assertion to get a string back

The trade-off: structs are clean and typed but rigid. Maps are flexible but every value comes out as interface{}, so we have to type-assert (.(string), .(float64)) everything. Also note: all JSON numbers decode into float64 in a map, never int.

Streaming with Encoder and Decoder

Marshal/Unmarshal work on byte slices held entirely in memory. For streams — like an HTTP request body or a file — we use json.NewEncoder and json.NewDecoder, which read/write directly to an io.Writer/io.Reader.

// in an HTTP handler — decode straight from the request body
func handler(w http.ResponseWriter, r *http.Request) {
	var u User
	json.NewDecoder(r.Body).Decode(&u) // read + parse the body in one step

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(u) // encode straight to the response writer
}

This is the idiomatic way in web servers — no need to read the whole body into a []byte first.

Handling unknown / dynamic JSON

Sometimes part of the JSON is unpredictable. We use json.RawMessage to defer decoding a chunk until later, or decode into a map and inspect a field first.

type Envelope struct {
	Type string          `json:"type"`
	Data json.RawMessage `json:"data"` // keep raw, decode later based on Type
}

This lets us peek at Type, then unmarshal Data into the right struct. Handy for APIs where the payload shape depends on a field.

Interview soundbite

encoding/json marshals Go structs to JSON and unmarshals back, but it only sees exported (capitalized) fields — the classic silent bug. Struct tags like json:"name,omitempty" rename and conditionally drop fields. Decode into a struct when we know the shape, into a map[string]interface{} when it’s dynamic (everything comes out as interface{} and numbers as float64). For streams like HTTP bodies, json.NewDecoder/NewEncoder read and write directly to the reader/writer.


28

Generics

intermediate go stdlib

Generics 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.

They landed in Go 1.18 — a huge addition the community had wanted for over a decade.

In simple language, generics let us say “this function works for any type T, and the compiler fills in the real type at compile time.”

The problem they solve

Before generics, a Max function needed one copy per type. Painful.

func MaxInt(a, b int) int       { if a > b { return a }; return b }
func MaxFloat(a, b float64) float64 { if a > b { return a }; return b }
// ...and so on, forever

With generics it’s one function:

import "golang.org/x/exp/constraints"

// T is a type parameter, constrained to ordered types (things we can compare with >)
func Max[T constraints.Ordered](a, b T) T {
	if a > b {
		return a
	}
	return b
}

func main() {
	fmt.Println(Max(3, 5))       // works with int
	fmt.Println(Max("a", "b"))   // works with string
	fmt.Println(Max(1.5, 0.5))   // works with float64
}

The [T constraints.Ordered] part is the type parameter list. T is a placeholder, and constraints.Ordered says “T must be a type that supports <, >, etc.” The compiler usually infers T from the arguments, so we just call Max(3, 5) normally.

Constraints — the rules on T

A constraint says what a type parameter is allowed to be. We can’t just do a > b on any type — only on comparable/ordered ones. Constraints enforce that. There are a few sources:

  • comparable — a built-in constraint for types that support == and != (needed for map keys, equality checks).
  • The constraints package (golang.org/x/exp/constraints) — ready-made ones like Ordered, Integer, Float, Signed.
  • any — the loosest constraint, an alias for interface{}. Means “literally any type.”
  • Custom interface constraints — we write our own.
// a custom constraint: T must be one of these number types
type Number interface {
	~int | ~int64 | ~float64 // | means "union", ~ means "any type based on this"
}

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

The ~int (with a tilde) means “int or any type whose underlying type is int” — so a custom type Celsius int still qualifies. Without the ~ it would only match int exactly.

Generic types, not just functions

Structs can be generic too. A type-safe stack that holds any type:

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

func (s *Stack[T]) Push(item T) { s.items = append(s.items, item) }
func (s *Stack[T]) Pop() T {
	last := s.items[len(s.items)-1]
	s.items = s.items[:len(s.items)-1]
	return last
}

// usage — we specify the type once: Stack[string]
var s Stack[string]
s.Push("hi")

Now Stack[int] and Stack[string] are separate, fully type-checked types. No interface{} casting, no runtime surprises.

Generics vs interfaces vs any — when to use what

This is the real interview question. Here’s the mental model:

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.
  • Generics — when the same algorithm runs over different types and we want to keep static type safety. The function body doesn’t care what T is.
  • Interfaces — when different types provide different behavior behind a shared method set. The whole point is polymorphism (different code per type).
  • any — when we genuinely don’t care about the type and accept losing type safety (and paying for runtime type assertions).

The community caution: don’t overuse them

The Go team’s own advice: reach for generics last, not first. They make code harder to read and were deliberately kept minimal.

In simple language — if a plain interface or a concrete type does the job, use that. Generics earn their place only when we’d otherwise copy-paste the exact same logic for several types (containers, and utilities like Map/Filter/Max). Sprinkling [T any] everywhere is a code smell.

Interview soundbite

Generics arrived in Go 1.18 and let us write one function or type parameterized over types, like func Max[T constraints.Ordered](a, b T) T. Constraints (comparable, the constraints package, or custom interface unions with ~ and |) restrict what T can be. Use generics when the same logic spans many types, interfaces when types share different behavior, and any only as a last resort. The Go team’s guidance is explicit: don’t overuse them.


29

Go’s standard library is famously “batteries included” — HTTP servers, JSON, crypto, testing, all ship with the language. We rarely need third-party packages for the basics.

In simple language, a lot of stuff that needs an npm install in Node is just there in Go.

This is a quick tour of the packages we reach for daily.

fmt — formatting and printing

The package we use on day one. Printing, formatting strings, scanning input.

fmt.Println("hello")                 // print with a newline
s := fmt.Sprintf("%s is %d", "Go", 15) // build a string, don't print it
fmt.Printf("%v\n", []int{1, 2, 3})   // %v = default format of any value
fmt.Printf("%+v\n", struct{ X int }{1}) // %+v adds field names: {X:1}

The verbs to remember: %v (any value), %+v (with field names), %d (int), %s (string), %T (the type itself).

strings & strconv — text and conversions

strings handles text manipulation. strconv converts between strings and numbers.

strings.Contains("golang", "go")     // true
strings.Split("a,b,c", ",")          // ["a" "b" "c"]
strings.ToUpper("hi")                // "HI"
strings.TrimSpace("  hi  ")          // "hi"

n, _ := strconv.Atoi("42")           // string → int (Atoi = ASCII to int)
str := strconv.Itoa(42)              // int → string
f, _ := strconv.ParseFloat("3.14", 64) // string → float64

Don’t confuse them: strconv is for number conversions, strings is for text operations.

time — the famous reference layout

Go does date formatting differently from everyone else. Instead of YYYY-MM-DD, we write out a specific reference date: 2006-01-02 15:04:05.

Why those numbers? They’re a mnemonic: 1, 2, 3, 4, 5, 6, 7 → 01/02 03:04:05PM '06 -0700 (month, day, hour, minute, second, year, timezone).

now := time.Now()
formatted := now.Format("2006-01-02 15:04:05") // e.g. 2026-05-30 14:30:00
parsed, _ := time.Parse("2006-01-02", "2026-05-30")

d := 3 * time.Hour            // durations are typed
time.Sleep(2 * time.Second)   // pause
elapsed := time.Since(now)    // how long since a time

That 2006-01-02 15:04:05 layout trips everyone up the first time — interviewers love asking about it. Just memorize the reference date.

os, io, bufio — files and streams

os talks to the operating system (files, args, env vars). io defines the universal Reader/Writer interfaces. bufio adds buffering for efficient reads.

data, _ := os.ReadFile("config.txt") // read a whole file into []byte
os.WriteFile("out.txt", data, 0644)  // write a file (0644 = permissions)

arg := os.Args[1]            // command-line arguments
home := os.Getenv("HOME")    // environment variable

// read a file line by line with bufio (efficient for big files)
f, _ := os.Open("big.txt")
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
	fmt.Println(scanner.Text()) // one line at a time
}

io.Reader and io.Writer are everywhere in Go — files, network connections, HTTP bodies, and buffers all implement them, so the same code works across all of them.

sort — ordering things

sort orders slices. There are quick helpers for the common types and a generic-ish sort.Slice for custom logic.

nums := []int{3, 1, 2}
sort.Ints(nums)                  // [1 2 3]

words := []string{"c", "a", "b"}
sort.Strings(words)              // [a b c]

// custom sort with a less-function
people := []struct{ Age int }{{30}, {20}}
sort.Slice(people, func(i, j int) bool {
	return people[i].Age < people[j].Age // sort by age ascending
})

slices & maps — the modern helpers (Go 1.21+)

Go 1.21 added the slices and maps packages — generic helpers that save us from writing the same loops over and over.

import "slices"

nums := []int{3, 1, 2}
slices.Sort(nums)                  // simpler than sort.Ints
slices.Contains(nums, 2)           // true
i, found := slices.BinarySearch(nums, 2)
maxVal := slices.Max(nums)         // 3
import "maps"

m := map[string]int{"a": 1, "b": 2}
keys := maps.Keys(m)   // iterator over keys (Go 1.23+ returns an iterator)
clone := maps.Clone(m) // shallow copy of the map

Before these, we hand-wrote a loop every time we wanted to check if a slice contained a value. Now it’s one call. If an interviewer asks how you’d check membership in a slice, mention slices.Contains — it shows you know modern Go.

Why this matters in interviews

When asked “why Go for backend?”, a strong answer is the standard library. We get a production HTTP server, JSON, testing, crypto, and time handling without pulling in a single dependency. Fewer dependencies means fewer supply-chain risks and less version churn.

Interview soundbite

Go’s standard library is “batteries included” — net/http, encoding/json, testing, and crypto all ship with the language. Daily drivers are fmt (printing, %v/%+v), strings/strconv (text vs number conversions), time with its quirky 2006-01-02 15:04:05 reference layout, os/io/bufio (the universal Reader/Writer interfaces), sort, and the newer generic slices/maps packages from 1.21+. Leaning on the stdlib over third-party deps is a real selling point of the language.


Testing & Production

30

Testing (Table-Driven)

intermediate go testing

Testing in Go is built right into the language. No frameworks, no extra installs — we just write functions and run go test. That’s a big selling point.

In simple language, the testing package is the standard library’s tool for writing and running tests, and the go command knows how to find and run them.

The basic rules

Tests live in files ending in _test.go. So the tests for math.go go in math_test.go, right next to it in the same package.

A test is just a function named TestXxx that takes one argument, *testing.T. The name must start with a capital T-est and the next letter must be uppercase.

// math_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
	got := Add(2, 3)
	if got != 5 {
		t.Errorf("Add(2, 3) = %d; want 5", got) // report failure, keep going
	}
}

t.Errorf marks the test as failed but keeps running the rest of the function. If we want to stop immediately (say, after a fatal setup error), we use t.Fatal / t.Fatalf instead — that fails and bails out of the function right there.

In simple language: t.Error = “this is wrong, but carry on”; t.Fatal = “this is wrong, stop now”.

The table-driven pattern (learn this cold)

This is the idiomatic way to test in Go, and it’s the answer interviewers want to hear. Instead of writing five near-identical test functions, we make a slice of test cases and loop over them.

Each case becomes its own subtest with t.Run, so they show up individually in the output and we can run just one.

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) { // named subtest per case
			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)
			}
		})
	}
}

Here’s how that flow looks:

[]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

Why we love this: adding a new case is one line, the failure message tells us exactly which case broke, and we can run a single case with go test -run TestAdd/negatives.

Running tests

go test                 # run tests in the current package
go test ./...           # run tests in this package and ALL subpackages
go test -v ./...        # verbose: show each test and subtest name
go test -run TestAdd     # run only tests matching this regex
go test -cover ./...     # show % of code covered by tests

The ./... pattern is the one we use most — it means “everything under here, recursively”.

No assertion library needed

Coming from other languages, people often ask “where’s assert?” Go doesn’t ship one on purpose — we use plain if checks and t.Error. It’s more verbose but dead simple.

If we really want assertions, the popular third-party choice is testify (github.com/stretchr/testify), which gives us assert.Equal(t, want, got) and friends. Lots of teams use it, but it’s optional — plenty of big Go codebases use the standard library only.

Interview soundbite

Go testing is built in: tests go in _test.go files as func TestXxx(t *testing.T), run with go test ./.... We use t.Error to fail-and-continue and t.Fatal to fail-and-stop. The idiomatic pattern is table-driven tests — a slice of case structs looped over with t.Run subtests — which keeps tests DRY and pinpoints which case failed. No assertion library is needed, though testify is the popular optional one.


31

Benchmarks & Profiling

advanced go testing performance

Benchmarks tell us how fast our code is, and profiling tells us where the time and memory go. Both ship with the standard testing package — same go test command, no extra tooling.

In simple language: a benchmark times a snippet over and over to get a stable number, and a profile is a recording of where our program spent its time or memory.

Writing a benchmark

A benchmark is a function named BenchmarkXxx that takes *testing.B. The magic is b.N — the testing framework runs our loop b.N times, automatically cranking N up until it has a reliable measurement.

// math_test.go
package math

import "testing"

func BenchmarkAdd(b *testing.B) {
	for i := 0; i < b.N; i++ { // run b.N times — the framework picks N
		Add(2, 3)
	}
}

We never set b.N ourselves — Go does. It might run 100 times or 10 million times depending on how fast Add is.

If we have expensive setup before the loop that shouldn’t count, we call b.ResetTimer() right before the loop so the timer ignores the setup.

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

Running and reading benchmarks

go test -bench=.            # run all benchmarks in the package
go test -bench=. -benchmem  # also report memory allocations
go test -bench=Add          # run only benchmarks matching "Add"

The -benchmem flag is the one to remember. Output looks like this:

BenchmarkAdd-8    1000000000    0.30 ns/op    0 B/op    0 allocs/op

Reading it left to right:

  • -8 — number of CPU cores used (GOMAXPROCS).
  • 1000000000 — how many times it ran (b.N).
  • 0.30 ns/opnanoseconds per operation. Lower is faster.
  • 0 B/op — bytes allocated per operation.
  • 0 allocs/opnumber of heap allocations per operation. This is huge — allocations trigger garbage collection, so fewer allocs usually means faster, calmer code.

In interviews, ns/op (speed) and allocs/op (memory pressure) are the two numbers people care about.

Profiling with pprof

Benchmarks say “this is slow.” pprof says “this line is slow.” We capture a profile, then explore it with go tool pprof.

# capture CPU and memory profiles from a benchmark
go test -bench=. -cpuprofile=cpu.out -memprofile=mem.out

# explore interactively (try `top`, `list FuncName`, `web`)
go tool pprof cpu.out

Inside pprof, 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 use benchmarks — we import net/http/pprof, which auto-registers debug endpoints on our HTTP server.

import _ "net/http/pprof" // blank import wires up /debug/pprof/* handlers

// then, with a running server, profile it live:
// go tool pprof http://localhost:8080/debug/pprof/profile?seconds=30

That blank import (_) is the trick — we don’t call anything, the package’s init() registers the handlers for us.

The race detector

Concurrency bugs are sneaky — two goroutines touching the same memory without synchronization. The -race flag instruments our code to catch these at runtime.

go test -race ./...   # run tests with the race detector on
go run -race main.go  # or run a program with it

If two goroutines access the same variable and at least one writes, -race prints a loud report with both stack traces. It’s slower and uses more memory, so we use it in CI and testing, not production. Interviewers love asking “how do you find data races?” — the answer is -race.

Interview soundbite

Benchmarks are func BenchmarkXxx(b *testing.B) with a for i := 0; i < b.N; i++ loop; run them with go test -bench=. -benchmem and read ns/op for speed and allocs/op for memory pressure, using b.ResetTimer() to exclude setup. To find where the cost is, we profile with -cpuprofile/-memprofile and go tool pprof, or import net/http/pprof for live servers. And go test -race catches data races at runtime.


32

Go Tooling

beginner go testing tooling

One of the best things about Go is that the tooling is built in. Compiler, test runner, formatter, doc viewer, dependency manager — all one go command. No webpack, no Babel, no debates about which formatter to use.

In simple language: 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 in one step (great for scripts/dev)
go build           # compile to a binary, no run
go test ./...      # run all tests recursively
go install         # build and drop the binary in $GOPATH/bin

go build produces a single static binary with no runtime dependencies — we copy that one file to a server and it just runs. That’s why Go is so popular for containers and CLIs.

Formatting — there’s only one true format

gofmt (and its wrapper go fmt) formats our code into the one canonical Go style. Tabs, spacing, alignment — all decided for us. There are no style debates in Go because there’s literally one accepted format.

go fmt ./...   # format all files in the module
gofmt -d file.go   # show what WOULD change (diff), don't write

Most teams run gofmt on save. goimports is the popular upgrade — it does everything gofmt does plus automatically adds and removes import statements for us.

goimports -w main.go   # format AND fix imports, write in place

go vet — catch suspicious code

go vet is a built-in static checker. It doesn’t check style (that’s gofmt’s job) — it flags likely bugs: a Printf with the wrong number of arguments, an unreachable line, a struct tag typo, a lock copied by value.

go vet ./...   # report suspicious constructs

Think of it as a free, fast first-pass bug catcher. It runs automatically as part of go test, too.

Managing dependencies

Dependencies are handled by Go modules. The two commands we lean on:

go get github.com/some/pkg   # add or upgrade a dependency
go mod tidy                  # add missing deps + remove unused ones

go mod tidy is the housekeeper — after we add or delete imports, it syncs go.mod and go.sum so they exactly match what the code actually uses. Run it before committing.

Reading docs and generating code

go doc fmt.Println        # print docs for a function, right in the terminal
go doc -all strings       # full docs for a whole package
go generate ./...          # run //go:generate directives in the code

go doc reads the doc comments straight from source — no internet needed. go generate runs commands we’ve annotated in comments (//go:generate ...), commonly used to produce mocks or stringer methods. It’s not automatic; we run it ourselves.

The standard tools cover a lot, but two community linters go further:

  • staticcheck — a deep static analyzer that catches many subtle bugs and dead code go vet misses.
  • golangci-lint — a fast meta-linter that bundles staticcheck, go vet, and dozens of others behind one config. This is the de-facto CI standard in most Go shops.
golangci-lint run ./...   # run the whole linter bundle

These aren’t built in, but every serious team installs golangci-lint. Worth name-dropping in an interview.

Interview soundbite

Go’s toolchain is built in — one go command does build, run, test, fmt, vet, doc, and module management, and go build spits out a single static binary. gofmt enforces one canonical format so there are no style arguments, goimports also fixes imports, go vet catches likely bugs, and go mod tidy syncs dependencies. The popular third-party linters are staticcheck and golangci-lint, the CI standard.


33

Project Structure & Dependencies

intermediate go testing modules

A Go project is organized into a module (the unit of versioning and dependencies) made of packages (folders of related code). Get this structure 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.

Modules recap

We start a module with go mod init, naming it after where it lives (usually its import path).

go mod init github.com/pman47/myapp   # creates go.mod

This creates two files we should understand:

  • go.mod — lists our module name, Go version, and our direct dependencies with their versions.
  • go.sum — cryptographic checksums of every dependency (and their deps), so builds are verifiable and tamper-proof.

Versions use semantic import versioning: v1.2.3 style tags. The big rule — if a library hits v2 or higher, its major version becomes part of the import path (github.com/foo/bar/v2). That lets v1 and v2 coexist without clashing.

go get github.com/foo/bar@v1.4.0   # pin a specific version
go mod tidy                         # sync go.mod/go.sum to actual imports

Vendoring is optional — go mod vendor copies all dependencies into a local vendor/ folder so builds don’t need the network. Some teams vendor for reproducible, offline-friendly builds; many skip it and rely on the module cache.

The common layout

There’s no enforced project layout, but the community converged on a convention. Here’s the typical shape:

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/ — one subfolder per executable, each with a package main and main.go. A repo with an API and a CLI would have cmd/api/ and cmd/cli/.
  • internal/ — see the special rule below.
  • pkg/ — library code we’re happy for outside projects to import. (Some people skip pkg/ and put packages at the root — that’s fine too.)

The special internal rule

This is the one interviewers love. internal/ isn’t just a naming convention — the compiler enforces it. Code inside an internal/ directory can only be imported by code rooted in the parent of that internal/ folder.

In simple language: internal/ means “private to this module — outsiders can’t import it.” If another repo tries to import github.com/pman47/myapp/internal/db, the build fails. It’s how we keep implementation details from leaking into other people’s code.

Package naming conventions

  • Package names are short, lowercase, single wordhttp, json, user. No underscores, no camelCase.
  • The package name should match its folder name.
  • Avoid stutter. If the package is user, name the constructor user.New(), not user.NewUser() — callers already type user., so repeating it reads as user.NewUser. Same reason it’s http.Server, not http.HTTPServer.

Avoiding circular imports

Go forbids circular imports — if package a imports b, then b cannot import a. The build just refuses. This sounds annoying but it forces clean, one-directional dependencies.

When we hit a cycle, the usual fixes are:

  • Pull the shared types into a third, lower-level package both can import.
  • Use an interface so the lower package doesn’t need to know the higher one (dependency inversion).

A cycle is almost always a signal that two packages are really one concern split badly, or that a dependency is pointing the wrong way.

Interview soundbite

A Go module (go mod init, tracked by go.mod/go.sum with semantic import versioning) groups packages, and the community layout is cmd/ for entry points, internal/ for private code, and pkg/ for public libraries. The killer detail is that internal/ is compiler-enforced — only the parent module can import it. Package names stay short and stutter-free (http.Server, not http.HTTPServer), and Go flat-out forbids circular imports, which we break by extracting shared types or introducing an interface.


34

Idioms & Best Practices

intermediate go testing idioms

This is the capstone note — a grab-bag of idiomatic Go. None of it is hard, but knowing these proverbs by name is the difference between “I write Go” and “I write idiomatic Go” in an interview.

In simple language: Go has a strong culture of “the right way to do things,” and a lot of it is captured in short proverbs.

Accept interfaces, return structs

When writing a function, take the most general type we can (an interface), but return a concrete type (a struct).

Why? Accepting an interface lets callers pass anything that fits — easy to test, easy to swap. Returning a concrete struct gives callers the full, specific type with all its methods, instead of a stripped-down interface.

// Good: accepts any io.Reader, returns a concrete *Parser
func NewParser(r io.Reader) *Parser {
	return &Parser{src: r}
}

The standard library does this everywhere — io.Reader in, concrete types out.

Keep interfaces small

The best Go interfaces are tiny — one or two methods. io.Reader is one method (Read). Small interfaces are easy to implement, easy to mock, and compose well.

A related proverb: “The bigger the interface, the weaker the abstraction.” If our interface has ten methods, it’s not really an abstraction, it’s a wishlist. 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. This is verbose but explicit — we always know exactly where things can fail.

f, err := os.Open("config.json")
if err != nil {
	return fmt.Errorf("opening config: %w", err) // wrap with context, return up
}
defer f.Close()

We wrap errors with %w to add context as they bubble up, and the caller can unwrap them with errors.Is / errors.As. The proverb: “Don’t just check errors, handle them gracefully.”

panic is reserved for truly unrecoverable situations (programmer bugs, impossible states) — not for normal control flow. In simple language: a missing file is an error; indexing past the end of a slice is a panic. Don’t panic for things we expect to happen.

Make the zero value useful

When we design a struct, try to make it work straight out of the box with no constructor — its zero value (all fields at their defaults) should be usable.

The classic examples: sync.Mutex is ready to lock the moment it’s declared, and bytes.Buffer is ready to write to. No New() needed.

var mu sync.Mutex   // zero value is an unlocked, ready-to-use mutex
mu.Lock()           // works immediately, no initialization

If users have to call Init() before our type does anything, we’ve made their life harder. Aim for “declare and go.”

Prefer composition over inheritance

Go has no class inheritance. Instead we embed types — drop one struct inside another and its methods get promoted up. We build big things out of small things rather than extending a base class.

type Logger struct{ prefix string }
func (l Logger) Log(msg string) { /* ... */ }

type Server struct {
	Logger // embedded — Server now has Log() for free
	port int
}

Server gets Log() automatically. This is “composition,” and it’s the only inheritance-like mechanism Go gives us — by design.

A little copying beats a little dependency

A famous Go proverb. If pulling in a whole package just to use one tiny helper, it’s often cleaner to copy that handful of lines into our code. Fewer dependencies means less to break, less to audit, less version hell.

It’s not about being lazy — it’s about not coupling our whole project to a library for the sake of ten lines.

Naming: short, no stutter

  • Short names for short scopes — i, r, buf are fine inside a small function.
  • Exported names start with a capital letter; unexported with lowercase. That’s the entire visibility system.
  • No stutter — it’s http.Server, never http.HTTPServer, because callers already wrote http.. Same idea: user.New() over user.NewUser().

And finally — gofmt everything

There’s one canonical format and gofmt enforces it, so we never argue about braces or spaces. “Gofmt’s style is no one’s favorite, yet gofmt is everyone’s favorite.” Just run it and move on.

Interview soundbite

Idiomatic Go in a nutshell: accept interfaces and return structs, keep interfaces tiny (the bigger the interface, the weaker the abstraction), and define them at the consumer. Errors are values we handle and wrap with %wpanic is only for unrecoverable bugs, not control flow. Design types so the zero value is useful, prefer composition via embedding over inheritance, remember “a little copying is better than a little dependency,” name things short with no stutter, and gofmt everything. It’s all in Effective Go.