Go Interview Questions and Answers
Last updated:
Check out 60 of the most common Go interview questions, then take an AI-powered practice interview
Q1What problems was Go designed to solve, and where does it actually sit in a 2026 backend stack?
BasicFundamentals
Answer
Go was designed at Google (Rob Pike, Ken Thompson, Robert Griesemer, released 2009) to fix three specific pains in their C++ and Java monorepos: multi-minute build times, dependency sprawl, and the difficulty of writing correct concurrent servers. The answers baked into the language are fast compilation to a single statically linked binary, an intentionally small language (25 keywords, no inheritance, no exceptions in the Java sense), garbage collection tuned for low pause times, and concurrency as a language feature through goroutines and channels rather than a threading library. In 2026 Go occupies two distinct niches.
First, it is the de facto language of cloud infrastructure: Kubernetes, Docker, Terraform, Prometheus, etcd, Vault, and most CNCF projects are Go codebases, so platform and SRE roles assume it. Second, it is a mainstream service language for high-throughput APIs: Uber's engineering team has written publicly about running thousands of Go services, and in India the openings cluster in payments, broking, and real-time consumer platforms, the Razorpay, Zerodha, CRED, and Gojek tier of employer, exactly the workloads where Node.js struggles with CPU-bound work and Java feels heavy on memory. Interviewers open with this question to check whether you understand the trade-offs: you give up generics-heavy abstraction (only partially fixed since Go 1.18), inheritance, and a huge framework ecosystem, and in exchange you get boring, readable code, tiny container images, predictable latency, and a runtime that schedules a million goroutines on a handful of threads. A good answer names those trade-offs instead of just praising the language.
Key Points
- Built for fast builds, static binaries, and easy concurrency
- Owns cloud infra: Kubernetes, Docker, Terraform, Prometheus are Go
- Indian employers hiring for Go: Razorpay, Zerodha, CRED, Swiggy, Gojek
- Trade-off: less abstraction power, more readability and predictability
Q2How is a goroutine different from an OS thread, and what does one actually cost?
BasicConcurrency
Answer
A goroutine is a lightweight execution unit managed by the Go runtime, not the kernel. An OS thread carries a fixed stack of around 1 MB (8 MB default on Linux with pthreads unless tuned) and switching between threads requires a kernel context switch costing microseconds. A goroutine starts with a stack of roughly 2 KB that grows and shrinks dynamically as needed, and switching between goroutines is a user-space operation the runtime performs in tens of nanoseconds at known preemption points.
That is why idiomatic Go servers casually run hundreds of thousands of goroutines, one per connection or request, while a thread-per-connection design collapses around a few thousand. The runtime multiplexes goroutines onto a small pool of OS threads (the GMP scheduler, covered in a later question), parking goroutines that block on channels or network I/O without blocking the underlying thread, thanks to the integrated netpoller built on epoll and kqueue. You start one with the go keyword: go doWork(job).
Interviewers probe two follow-ups. First, goroutines are cheap but not free: each one still costs a couple of kilobytes plus scheduler bookkeeping, so spawning one per item of a ten-million-element slice is a bug, not a flex; use a worker pool. Second, the go statement returns immediately and main exiting kills every goroutine mid-flight, so you must synchronise with sync.WaitGroup or channels, never with time.Sleep. Mentioning that blocking syscalls (like file I/O) do occupy a thread, and that the runtime spins up more threads to compensate, signals real understanding.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
results := make(chan int, 5)
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
results <- n * n
}(i)
}
go func() {
wg.Wait()
close(results)
}()
for r := range results {
fmt.Println(r)
}
}
Q3Explain arrays versus slices in Go. What exactly is inside a slice header?
BasicData Structures
Answer
An array in Go is a fixed-size value type: [4]int and [5]int are different, incompatible types, and assigning or passing an array copies every element. Arrays are rare in application code. A slice is the workhorse: a small descriptor (the slice header) containing three words, a pointer to an element in a backing array, a length, and a capacity.
When you pass a slice to a function you copy that 24-byte header (on 64-bit), not the elements, which is why functions can mutate the elements a caller sees but cannot change the caller's length by appending. len(s) is how many elements you can index; cap(s) is how many the backing array can hold from the slice's start before a new allocation is needed. Slicing an existing slice (s[2:5]) creates a new header over the same backing array, no copying, which is both Go's superpower for zero-copy parsing and the source of its most classic memory bugs: a tiny slice of a 100 MB buffer keeps the whole buffer alive for the garbage collector. Interviewers reliably test three behaviours: that make([]int, 3, 10) gives length 3 and capacity 10; that a nil slice is safe to append to and to range over (unlike a nil map); and that copy(dst, src) copies min(len(dst), len(src)) elements, so copying into an empty slice silently copies nothing. Since Go 1.21 the standard slices package (slices.Contains, slices.Sort, slices.Clone) replaces most hand-rolled loops and shows up in code review rounds.
package main
import "fmt"
func main() {
s := make([]int, 3, 10)
fmt.Println(len(s), cap(s)) // 3 10
a := []int{1, 2, 3, 4, 5}
b := a[1:3] // shares backing array with a
fmt.Println(b, len(b), cap(b)) // [2 3] 2 4
b[0] = 99
fmt.Println(a) // [1 99 3 4 5] (a sees the write!)
var nilSlice []int // nil, but append works
nilSlice = append(nilSlice, 1)
fmt.Println(nilSlice) // [1]
}
Key Points
- Slice header = pointer + length + capacity (3 words)
- Passing a slice copies the header, not the elements
- Sub-slicing shares the backing array; writes are visible to both
- nil slices are safe to append and range; nil maps are not safe to write
Q4How do Go maps behave around nil values, missing keys, iteration order, and concurrent access?
BasicData Structures
Answer
Four behaviours around maps decide whether you look like a Go engineer or a tourist. First, the zero value of a map is nil: reading from a nil map returns the value type's zero value without panicking, but writing to one panics with "assignment to entry in nil map", so you must initialise with make(map[string]int) or a literal before writing. Second, a missing key is indistinguishable from a stored zero value unless you use the comma-ok idiom: v, ok := m[key].
Forgetting this is a real bug class, for example a balances map where a missing user and a zero balance mean different things. Third, iteration order is deliberately randomised: the runtime seeds each range over a map differently precisely so nobody depends on ordering. If you need stable output (API responses, tests, file generation), collect the keys, sort them with slices.Sort, and iterate over that.
Fourth, maps are not safe for concurrent use: one writer plus any other reader or writer triggers the runtime's built-in concurrent map access detector, which crashes the process with "fatal error: concurrent map writes", and that crash is not recoverable with recover(). Guard maps with a sync.Mutex or sync.RWMutex, or use sync.Map for the narrow append-mostly, read-mostly caching cases it was designed for. Worth mentioning in senior interviews: Go 1.24 replaced the runtime's map implementation with a Swiss-table design, giving measurably faster lookups and lower memory overhead with zero code changes, a nice example of the compatibility promise doing real work. Also remember delete(m, key) is a no-op for missing keys, and clear(m) (Go 1.21) empties a map in place.
package main
import "fmt"
func main() {
var nilMap map[string]int
fmt.Println(nilMap["x"]) // 0, reading nil map is fine
// nilMap["x"] = 1 // panic: assignment to entry in nil map
balances := map[string]int{"asha": 0}
v, ok := balances["asha"]
fmt.Println(v, ok) // 0 true (real zero balance)
v, ok = balances["ravi"]
fmt.Println(v, ok) // 0 false (no such user)
delete(balances, "missing") // safe no-op
clear(balances) // Go 1.21+: empty in place
fmt.Println(len(balances)) // 0
}
Q5What are zero values in Go, and why are types like sync.Mutex and bytes.Buffer usable without initialisation?
BasicLanguage Design
Answer
Every declared variable in Go is automatically initialised to its type's zero value: 0 for numeric types, "" for strings, false for bool, and nil for pointers, slices, maps, channels, functions, and interfaces. Structs are zeroed field by field, recursively. There is no such thing as an uninitialised variable, which eliminates an entire C-era bug class.
The deeper design principle, stated in Effective Go, is "make the zero value useful", and the standard library takes it seriously: var mu sync.Mutex is an unlocked mutex ready to Lock(); var b bytes.Buffer is an empty buffer ready for Write(); var wg sync.WaitGroup is ready for Add(); var sb strings.Builder works immediately. This is why idiomatic Go constructors are often unnecessary, and why embedding these types into your own structs gives working behaviour for free. The pattern has sharp edges you should name in an interview.
A nil map reads fine but panics on write. A nil channel blocks forever on send and receive (which select-based code sometimes exploits deliberately). A nil pointer panics on dereference but calling a method on a nil receiver is legal and often intentional, several standard library types document nil-receiver behaviour.
When designing your own types, the interview-grade habit is to ask: does MyType{} do something sane? If a struct needs validation or private setup, hide the fields and export a NewMyType() constructor returning (*MyType, error); if the zero value works, document that it does and skip the constructor. Copying a struct containing a sync.Mutex after first use is a bug (the copy shares no lock state and go vet's copylocks check flags it), another reason zero-value-usable types are usually held by pointer once they contain synchronisation.
Key Points
- All variables get deterministic zero values; nothing is uninitialised
- Design principle: make the zero value useful (sync.Mutex, bytes.Buffer)
- nil map writes panic; nil channel operations block forever
- go vet copylocks catches copying a struct with a used Mutex
Q6Explain defer: execution order, when arguments are evaluated, and the classic loop mistake.
BasicLanguage Semantics
Answer
defer schedules a function call to run when the surrounding function returns, whether it returns normally or panics. Three rules cover nearly every interview question about it. Rule one: deferred calls run LIFO, last deferred runs first, which naturally unwinds acquisitions in reverse order (open A, open B, close B, close A).
Rule two: the deferred function's arguments are evaluated immediately at the defer statement, not at execution time. defer fmt.Println(i) captures i's value now; if you need the final value, defer a closure: defer func() { fmt.Println(i) }(). This distinction is the single most common defer trick question. Rule three: defer is function-scoped, not block-scoped.
A defer inside a loop body does not run at the end of each iteration; every deferred call queues up until the function exits. The classic production bug is opening files or acquiring locks in a loop with defer f.Close() inside it: a loop over 10,000 files holds 10,000 descriptors until the function returns, and you hit "too many open files". The fix is extracting the loop body into its own function, or calling Close explicitly.
Two more points earn senior credit. Deferred closures can read and modify named return values, which is how you wrap errors on the way out: defer func() { if err != nil { err = fmt.Errorf("processing %s: %w", name, err) } }(). And defer's overhead is roughly a nanosecond since the open-coded defer optimisation in Go 1.14, so "defer is slow" is an outdated objection; do not avoid it in hot paths without a profile showing it matters.
package main
import "fmt"
func main() {
fmt.Println(counts()) // 3, not 0
for i := 0; i < 3; i++ {
defer fmt.Println("deferred:", i) // args captured now
}
// prints deferred: 2, 1, 0 (LIFO) when main returns
}
func counts() (n int) {
defer func() { n = 3 }() // can modify named return
return 0
}
Key Points
- LIFO execution when the function returns or panics
- Arguments evaluated at defer time; use a closure for late binding
- Function-scoped: defer in a loop leaks resources until return
- Deferred closures can rewrite named return values (error wrapping)
Q7How does error handling work in Go without exceptions, and what does fmt.Errorf with %w give you?
BasicError Handling
Answer
Errors in Go are ordinary values implementing a one-method interface: type error interface { Error() string }. Functions that can fail return an error as their last return value, and callers check it immediately: if err != nil { return err }. There is no try/catch and no hidden control flow; every failure path is visible in the code, which is exactly the property large teams value even though it costs vertical space.
The mistake beginners make is returning errors bare, so by the time an error surfaces at the top of a service it reads "connection refused" with no clue which of forty downstream calls produced it. The fix is wrapping: fmt.Errorf("loading user %d: %w", id, err) prepends context and, because of the %w verb (added in Go 1.13), preserves the original error in a chain that errors.Is and errors.As can walk later. Use %w when callers might need to inspect the underlying cause, and %v when you deliberately want to sever the chain at an API boundary so callers cannot couple to your internals.
Conventions interviewers look for: error strings start lowercase and carry no trailing punctuation (go vet and staticcheck ST1005 both flag violations); context describes what you were doing, not "error occurred"; errors are handled once, either logged or returned, never both, because double handling produces duplicate log lines that make incident debugging miserable. Also know errors.New for fixed sentinel errors and that comparing errors with == only works for sentinels; anything constructed with fmt.Errorf needs errors.Is. The if err != nil verbosity is a deliberate trade: Go chose explicitness over brevity, and various proposals to shorten it (try, check) have been rejected.
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func findUser(id int) error {
if id != 42 {
return fmt.Errorf("user %d: %w", id, ErrNotFound)
}
return nil
}
func main() {
err := findUser(7)
fmt.Println(err) // user 7: not found
// errors.Is walks the %w chain
if errors.Is(err, ErrNotFound) {
fmt.Println("handle the 404 case")
}
}
Q8When should a method use a pointer receiver versus a value receiver, and how do method sets affect interfaces?
BasicMethods
Answer
A value receiver (func (c Counter) Get() int) operates on a copy of the struct; a pointer receiver (func (c *Counter) Inc()) operates on the original. Use a pointer receiver when the method mutates state, when the struct is large enough that copying costs (as a loose rule, more than a few words), or when the struct contains fields that must not be copied, like a sync.Mutex, since a value receiver would copy the lock and go vet's copylocks check will flag it. Use value receivers for small immutable types like time.Time or your own small value types.
The consistency rule matters more than either: if any method needs a pointer receiver, give the type pointer receivers everywhere, because mixing them confuses both readers and the method set rules. Those rules are the interview trap. The method set of *T includes methods with both pointer and value receivers, but the method set of T includes only value-receiver methods.
Consequence: if Write is defined on *Buffer, then a Buffer value does not satisfy io.Writer, only *Buffer does, and var w io.Writer = myBuffer fails to compile with "Buffer does not implement io.Writer (method Write has pointer receiver)". The compiler auto-takes addresses for direct calls on addressable values (b.Write() becomes (&b).Write()), which hides the rule in everyday code and makes it feel arbitrary when interface assignment suddenly enforces it. Map elements are not addressable, so you cannot call a pointer-receiver method on m[key] directly; you must store pointers in the map or copy out, modify, and store back. A crisp answer states the mutation/size/lock criteria, the consistency rule, and reproduces that compiler error from memory.
package main
import "fmt"
type Counter struct{ n int }
func (c *Counter) Inc() { c.n++ }
func (c Counter) Get() int { return c.n }
type Incrementer interface{ Inc() }
func main() {
c := Counter{}
c.Inc() // compiler rewrites to (&c).Inc()
fmt.Println(c.Get()) // 1
var i Incrementer = &c // ok: *Counter has Inc
// var j Incrementer = c // compile error: Inc has pointer receiver
i.Inc()
fmt.Println(c.Get()) // 2
}
Key Points
- Pointer receiver: mutation, large structs, or embedded sync.Mutex
- Method set of T = value-receiver methods only; *T gets both
- Interface satisfaction is where the method set rule bites
- Be consistent: one receiver kind per type
Q9How does interface satisfaction work in Go, and why does the standard library prefer tiny interfaces like io.Reader?
BasicInterfaces
Answer
Go interfaces are satisfied implicitly, structurally rather than nominally: any type that has the methods an interface declares satisfies it, with no implements keyword and no declaration linking the two. This inverts the dependency direction you know from Java or C#: the consumer defines the interface it needs, and producers satisfy it without ever importing the consumer's package. That is why the idiom is "accept interfaces, return structs": a function takes io.Reader because it only needs Read, and any of a hundred types (os.File, bytes.Reader, strings.Reader, http request bodies, gzip readers, network connections) plug in unchanged.
The standard library's most-used interfaces are one or two methods: io.Reader, io.Writer, io.Closer, fmt.Stringer, sort.Interface, http.Handler. Small interfaces compose (io.ReadWriteCloser is three embedded one-method interfaces) and are trivial to fake in tests, which is the practical payoff: to test code that takes an io.Reader you pass strings.NewReader("fixture"), no mocking framework required. The Go proverb is "the bigger the interface, the weaker the abstraction".
Interviewers check three habits. One: define interfaces where they are consumed, not next to the implementation, and keep them minimal; a repository interface with fifteen methods defined in the storage package is a Java habit that Go reviewers push back on. Two: do not create an interface until a second implementation or a test seam actually needs it; premature interfaces are indirection without benefit. Three: to make satisfaction failures compile-time errors, use the blank-identifier assertion var _ http.Handler = (*Server)(nil), a standard trick that documents intent and breaks the build if the method set drifts.
package main
import (
"fmt"
"io"
"strings"
)
// consumer defines the minimal interface it needs
func countBytes(r io.Reader) (int, error) {
buf := make([]byte, 512)
total := 0
for {
n, err := r.Read(buf)
total += n
if err == io.EOF {
return total, nil
}
if err != nil {
return total, err
}
}
}
func main() {
n, _ := countBytes(strings.NewReader("namaste, go"))
fmt.Println(n) // 11
}
Key Points
- Implicit, structural satisfaction: no implements keyword
- Accept interfaces, return structs; define interfaces at the consumer
- One-method interfaces compose and are trivially fakeable in tests
- var _ Iface = (*T)(nil) makes satisfaction a compile-time check
Q10Explain unbuffered versus buffered channels, and what close(ch) does to senders, receivers, and range loops.
BasicChannels
Answer
A channel is a typed conduit between goroutines. An unbuffered channel (make(chan int)) has no storage: a send blocks until a receiver is ready and vice versa, so every transfer is a synchronisation point, a rendezvous. This is the correct default when you want handoff semantics or backpressure.
A buffered channel (make(chan int, 8)) decouples the two sides: sends succeed immediately until the buffer holds 8 items, then block; receives block only when the buffer is empty. Buffers smooth bursts but a buffer sized by guesswork is a common review comment; a large buffer mostly hides backpressure until production traffic finds it. close(ch) is a sender-side signal meaning "no more values". After close, receives drain any buffered values, then return the zero value immediately and forever; the two-value form v, ok := <-ch reports ok=false once drained, and for v := range ch loops exit cleanly.
The panic rules are strict and heavily tested in interviews: sending on a closed channel panics ("send on closed channel"), closing an already-closed channel panics, and closing a nil channel panics. Rules of thumb that follow: only the sender closes, never the receiver; with multiple senders, nobody closes directly, you coordinate with a sync.WaitGroup and close from a single goroutine after Wait returns, exactly the fan-in pattern. Also be precise that you rarely need to close channels at all: close is for signalling completion to a range loop or broadcast (a closed channel unblocks every receiver, which is how context cancellation works underneath); channels are garbage collected whether or not they are closed, so "cleanup" is not a reason.
package main
import (
"fmt"
"sync"
)
func main() {
jobs := make(chan int) // unbuffered: rendezvous
done := make(chan bool, 1) // buffered: send won't block
var wg sync.WaitGroup
wg.Add(2)
for w := 0; w < 2; w++ {
go func() {
defer wg.Done()
for j := range jobs { // exits when jobs is closed
fmt.Println("processed", j)
}
}()
}
for i := 1; i <= 4; i++ {
jobs <- i
}
close(jobs) // sender closes; range loops exit
wg.Wait()
done <- true
fmt.Println(<-done)
}
Q11How does select work, what is the default case for, and what happens when multiple cases are ready?
BasicChannels
Answer
select blocks until one of its channel operations (sends or receives) can proceed, then executes that case. It is the control structure that makes Go's concurrency composable: waiting on several channels at once is one statement, not a thread-and-callback dance. Three behaviours carry the interview.
First, when multiple cases are ready simultaneously, select picks one uniformly at random, deliberately, so no channel can starve the others; if an interviewer asks "which case wins", the answer is "a random ready one", not "the first". Second, a default case makes select non-blocking: if no channel operation is ready, default runs immediately. That gives you try-send and try-receive: select { case ch <- v: default: /* would block, drop or count */ } is the standard shape for shedding load into a metrics counter instead of blocking a hot path.
Be ready to defend dropping versus blocking; non-blocking sends silently lose data, which is fine for telemetry and wrong for payments. Third, select with no cases (select {}) blocks forever, and a case on a nil channel is never ready, which is a feature: setting a channel variable to nil inside a loop disables that case on subsequent iterations, the standard way to finish a fan-in loop channel by channel. The canonical production shapes to have ready on a whiteboard: timeout with ctx.Done() alongside the work channel; a heartbeat loop over a time.Ticker plus a shutdown channel; and worker loops of the form select { case job := <-jobs: ... case <-ctx.Done(): return }. In modern code, preferring ctx.Done() over ad hoc quit channels is the expected idiom.
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
result := make(chan string, 1)
go func() {
time.Sleep(100 * time.Millisecond) // slow backend
result <- "payment confirmed"
}()
select {
case r := <-result:
fmt.Println(r)
case <-ctx.Done():
fmt.Println("timed out:", ctx.Err()) // context deadline exceeded
}
// non-blocking try-send
events := make(chan string) // no buffer, nobody receiving
select {
case events <- "metric":
default:
fmt.Println("dropped: would block")
}
}
Key Points
- Multiple ready cases: uniformly random choice, prevents starvation
- default makes select non-blocking (try-send / try-receive)
- nil channel cases are never ready; used to disable cases in loops
- ctx.Done() in a select case is the standard cancellation shape
Q12How do exported identifiers, package layout, and internal/ directories control visibility in a Go codebase?
BasicProject Structure
Answer
Go has exactly one visibility rule: identifiers starting with an uppercase letter are exported from their package; lowercase identifiers are package-private. There is no public/private/protected keyword set and no class-level visibility, the package is the encapsulation boundary. All files in one directory belong to one package and see each other's unexported names, so a package is closer to a single class-sized unit than a Java package.
Struct fields follow the same rule, which has a practical consequence people miss: encoding/json cannot marshal unexported fields, so a struct of lowercase fields silently serialises to {}. Beyond capitalisation, the toolchain gives you one structural lever: any package under a directory named internal/ is importable only by packages rooted at internal/'s parent. github.com/acme/pay/internal/ledger can be imported by anything under github.com/acme/pay/, but an import from another module fails at compile time with "use of internal package ... not allowed". This is how you publish a module while keeping most of it off-limits, and the standard library itself uses internal/ heavily.
Layout conventions interviewers expect for a service: cmd/<appname>/main.go for each binary entry point, kept thin; business logic in internal/ packages named for what they provide (ledger, auth), not generic buckets like utils or helpers, both of which are review flags; a go.mod at the root defining the module path. The controversial pkg/ directory is optional and increasingly skipped. Also know that package names are lowercase, short, and singular by convention, and that the package name should not stutter with its contents: ledger.Entry, not ledger.LedgerEntry.
Key Points
- Capitalised = exported; the package is the encapsulation unit
- Unexported struct fields are invisible to encoding/json
- internal/ enforces import boundaries at compile time
- cmd/ for binaries, internal/ for logic, no utils packages
Q13Walk through Go modules: what go.mod and go.sum do, and what go get, go mod tidy, and semantic import versioning mean day to day.
BasicModules
Answer
A module is a versioned collection of packages with a go.mod file at its root declaring the module path (module github.com/acme/pay), the Go version directive, and require lines pinning each dependency to a semantic version. go.sum records cryptographic hashes of every module version you depend on (directly or transitively) so builds are tamper-evident; you commit both files, and CI failing on a go.sum mismatch means the dependency's content changed for the same version, which should never happen through the default GOPROXY (proxy.golang.org) and its checksum database. Daily commands: go get github.com/pkg/x@v1.4.2 adds or moves a dependency to a specific version (@latest, @none to drop it, or @commit-hash also work); go mod tidy reconciles go.mod and go.sum with what the code actually imports, adding what is missing and pruning what is unused, and running it is the standard fix for most "missing go.sum entry" build errors. Version selection uses MVS (minimal version selection): the build uses the minimum version satisfying every requirement, which makes builds reproducible without a lockfile solver.
The rule candidates most often fumble is semantic import versioning: from v2 onward, the major version is part of the import path. A module released as v2 declares module github.com/acme/lib/v2 and consumers import github.com/acme/lib/v2; v1 and v2 can coexist in one build because they are different paths. Also useful to name: replace directives for local development against a fork, the go.work workspace file for multi-module repos, GOPRIVATE for company-internal modules that must bypass the public proxy, and go mod vendor if the team vendors dependencies.
# start a module
go mod init github.com/acme/pay
# add / upgrade / remove dependencies
go get github.com/jackc/pgx/v5@v5.6.0
go get github.com/jackc/pgx/v5@latest
go get github.com/old/dep@none
# sync go.mod + go.sum with actual imports
go mod tidy
# why is this dependency in my build?
go mod why github.com/some/dep
go mod graph | grep some/dep
# private modules: skip the public proxy + checksum DB
go env -w GOPRIVATE=github.com/acme/*
Key Points
- go.mod declares requirements; go.sum pins content hashes; commit both
- MVS picks minimum satisfying versions: reproducible without a solver
- v2+ modules put the major version in the import path (/v2)
- go mod tidy is the fix for most missing/unused dependency errors
Q14What is the difference between a string, a []byte, and a rune in Go, and why can len(s) disagree with the number of characters?
BasicStrings
Answer
A Go string is an immutable sequence of bytes, not characters. Source files are UTF-8, so string literals hold UTF-8 encoded text, but nothing stops a string from containing arbitrary binary. len(s) returns bytes, so len("नमस्ते") is 18, not 6, because each Devanagari code point encodes to 3 bytes in UTF-8. A rune is an alias for int32 holding one Unicode code point.
Indexing s[i] gives you a byte (uint8), while ranging with for i, r := range s decodes UTF-8 and yields rune values with i being the starting byte offset, so indices jump by more than one for multibyte characters, a detail interviewers love. To count code points use utf8.RuneCountInString(s); to work with code points explicitly, convert with []rune(s), which allocates and copies. []byte(s) and string(b) also copy, because strings are immutable and byte slices are not; in hot paths that copy shows up in profiles, and the strings.Builder type exists specifically to build strings without repeated copying (naive s += chunk in a loop is quadratic). Practical gotchas worth naming: truncating a string at an arbitrary byte index can split a UTF-8 sequence and produce invalid text, so truncate at rune boundaries; "character" itself is slippery because user-perceived characters (grapheme clusters, like emoji with modifiers) can span multiple runes, and the standard library does not handle grapheme clusters, you need golang.org/x/text for that level of correctness. For Indian-language product work (Hindi, Tamil, Bangla content) these are not trivia, they decide whether name truncation and search behave correctly.
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "नमस्ते"
fmt.Println(len(s)) // 18 (bytes)
fmt.Println(utf8.RuneCountInString(s)) // 6 (code points)
for i, r := range s {
fmt.Printf("byte %d: %c\n", i, r) // i jumps 0,3,6,...
}
b := s[0] // a byte, not a character
fmt.Println(b) // 224
runes := []rune(s)
fmt.Println(string(runes[:3])) // नमस (safe truncation)
}
Q15Go has no inheritance. How does struct embedding work, and where does method promotion break the inheritance intuition?
BasicComposition
Answer
Go replaces inheritance with composition plus embedding. Embedding a type anonymously in a struct (type Server struct { *log.Logger }) promotes the embedded type's exported fields and methods to the outer type: server.Printf(...) works as if Server defined it. You can embed structs, pointers to structs, and interfaces (embedding an interface in a struct is how you satisfy a large interface while overriding only some methods, common in test fakes wrapping a real implementation).
Multiple embeddings are allowed; name collisions at the same depth are not resolved automatically, you must select explicitly (s.Logger.Printf), and a shallower name shadows a deeper one. The critical difference from inheritance, and the part interviews probe: there is no polymorphic dispatch back to the outer type. When a promoted method on the embedded type calls another method, it always calls the embedded type's version; the outer type "overriding" that method changes nothing for internal calls.
In Java, a subclass override is seen by superclass code calling this.method(); in Go, the embedded value has no idea it is embedded. The receiver is the embedded value, full stop. So template-method patterns from OO languages do not port; the Go equivalent is explicit: pass behaviour in as an interface or a function field.
A related design habit: embedding is a convenience for method forwarding, not an is-a relationship, and embedding a type in an exported struct also exports the ability to reach the embedded value directly (server.Logger), which widens your API surface; when you want a narrow API, use a named field and hand-write the two or three forwarding methods. Interviewers often close by asking you to model something with composition that you would have modelled with a base class, practice that transformation.
package main
import "fmt"
type Animal struct{ Name string }
func (a Animal) Describe() string { return a.Name + " says " + a.Sound() }
func (a Animal) Sound() string { return "..." }
type Dog struct{ Animal }
func (d Dog) Sound() string { return "woof" }
func main() {
d := Dog{Animal{Name: "Moti"}}
fmt.Println(d.Sound()) // woof (direct call, shadows promoted)
fmt.Println(d.Describe()) // "Moti says ..." NOT "woof"!
// Describe's receiver is the inner Animal; it cannot see Dog.Sound.
}
Key Points
- Embedding promotes fields and methods; it is forwarding, not is-a
- No virtual dispatch: embedded methods never see outer overrides
- Embed interfaces in structs to build partial fakes for tests
- Same-depth name collisions require explicit selection
Q16What does init() do, in what order do package initialisations run, and why do experienced Go teams minimise init usage?
BasicLanguage Semantics
Answer
init() is a special function, any package may declare any number of them (even several per file), that the runtime executes exactly once per package before main() runs. The full initialisation order is deterministic: first, imported packages initialise (recursively, dependencies before dependents); within a package, package-level variables initialise in dependency order (a variable initialised from another waits for it), then init() functions run in the order they appear, file order being the compiler's sorted file name order. Only after every transitively imported package completes does main.main start.
Two legitimate uses survive code review in 2026: registration patterns, like database/sql drivers calling sql.Register from init, which is why you see the blank import _ "github.com/lib/pq" whose entire purpose is triggering that init; and computing complex package-level tables that cannot be constant expressions. Beyond those, experienced teams treat init as a liability, and interviewers like hearing why. Init code runs before main, so it cannot take configuration, cannot return errors (its only failure mode is panic, taking the process down before your logger exists), runs even when the importer needed one small function, hides side effects behind imports which makes program behaviour depend on the import graph, and creates test-ordering surprises since tests also trigger it.
The modern preference is explicit constructors: NewServer(cfg) (Server, error) makes dependencies visible, testable, and fallible. A related trap: package-level variables with expensive initialisers (var db = mustConnect()) execute at import time in every binary and test that touches the package; lazy alternatives are sync.OnceValue or plain constructor injection. Being able to narrate "imports first, then vars in dependency order, then init, then main" precisely is the pass bar here.
Key Points
- Order: imported packages, package vars (dependency order), init(), main()
- Blank imports (_ "github.com/lib/pq") exist to trigger driver init
- init cannot return errors or accept config; panics kill startup
- Prefer explicit constructors; init hides side effects behind imports
Q17When are panic and recover appropriate in Go, and how does recover actually interact with defer?
BasicError Handling
Answer
panic aborts the normal flow of the current goroutine: deferred functions run as the stack unwinds, and if nothing recovers, the runtime prints the panic value plus a stack trace and exits the process with status 2. recover() is only meaningful inside a deferred function; called there during a panic, it stops the unwinding, returns the panic value, and lets the surrounding function return normally. Called anywhere else it returns nil and does nothing, a detail screening questions test directly. The Go position is that panics are for programmer bugs and unrecoverable states (index out of range, nil dereference, impossible invariants), while expected failures (file missing, network down, bad user input) are errors returned as values.
Legitimate panic use is narrow: package initialisation that cannot proceed (the MustCompile pattern, regexp.MustCompile panics instead of returning an error, appropriate for compile-time-constant patterns), and truly broken invariants where continuing would corrupt data. Legitimate recover use is also narrow but important: isolating failure domains. net/http recovers around each handler so one panicking request does not kill the server (though be aware it does this per-connection and a panic still kills that request), and worker pools commonly wrap each job in a deferred recover so one poisoned job cannot take down the fleet, logging the panic value and runtime/debug.Stack() for the postmortem. The anti-pattern interviewers screen for is exception-style control flow: panicking across package boundaries and recovering to simulate try/catch. Two more precise behaviours worth stating: a panic in one goroutine cannot be recovered by another (each goroutine has its own stack, so every long-lived goroutine you spawn needs its own recovery discipline), and re-panicking after inspecting the value (panic(v) inside the recover branch) is the correct way to pass on panics you do not own.
package main
import (
"fmt"
"runtime/debug"
)
func safeProcess(job string) (err error) {
defer func() {
if r := recover(); r != nil {
// convert a bug into an error at the goroutine boundary
err = fmt.Errorf("job %q panicked: %v\n%s", job, r, debug.Stack())
}
}()
return process(job)
}
func process(job string) error {
var m map[string]int
m[job] = 1 // panic: assignment to entry in nil map
return nil
}
func main() {
if err := safeProcess("invoice-42"); err != nil {
fmt.Println("recovered and reported")
}
}
Q18What is the difference between new and make in Go, and when do you actually use each?
BasicMemory
Answer
new(T) allocates zeroed memory for a value of type T and returns a *T. It performs no initialisation beyond zeroing, and it works for any type. make(T, ...) exists only for the three built-in types whose zero value is not ready for full use because they need internal runtime structures: slices, maps, and channels. make returns an initialised (non-nil) value of type T itself, not a pointer: make([]int, 0, 10) builds a slice header pointing at allocated backing memory, make(map[string]int, 100) builds hash-table internals with a size hint, and make(chan int, 8) builds the channel's internal buffer and lock structures. The reason both exist is the difference between zeroing and initialising: new(map[string]int) is legal but nearly always a bug, it returns a pointer to a nil map that panics the first time you write through it.
Same story for channels: a new'd channel pointer wraps a nil channel that blocks forever. In practice idiomatic Go uses new rarely; &T{} does the same job as new(T) for structs while letting you set fields in the same expression, so most teams standardise on the composite literal form and reserve new for the occasional pointer-to-zero-value of a non-struct type (new(int) when an API needs *int). Where make genuinely matters for performance: pre-sizing. make([]byte, 0, len(input)) before an append loop avoids the repeated grow-and-copy of append's amplification, and the capacity hint to make(map...) avoids incremental rehashing when you know you are about to insert a hundred thousand rows, both are standard review comments on hot paths. A senior-sounding close: new is about allocation, make is about initialisation, and the type system enforces the split because make's results must be usable immediately.
package main
import "fmt"
func main() {
p := new(int) // *int pointing at 0
fmt.Println(*p) // 0
mp := new(map[string]int) // *map, but the map itself is nil!
// (*mp)["x"] = 1 // panic: assignment to entry in nil map
_ = mp
m := make(map[string]int, 100) // initialised, ready to write
m["x"] = 1
s := make([]byte, 0, 4096) // pre-sized: no regrow in the loop
for i := 0; i < 4096; i++ {
s = append(s, byte(i))
}
fmt.Println(len(s), cap(s)) // 4096 4096
}
Key Points
- new(T) zeroes and returns *T; make initialises slice/map/chan and returns T
- new(map) / new(chan) compile but produce unusable nil values
- Idiomatic structs use &T{...} instead of new(T)
- make with capacity hints is a real optimisation on hot paths
Q19How do const and iota work in Go, and what makes untyped constants special?
BasicLanguage Basics
Answer
Go constants are compile-time values limited to basic types: booleans, numbers, and strings. You cannot have a const slice, map, or struct; those must be vars, which is why package-level lookup tables are variables by necessity. Constants can be typed (const Timeout time.Duration = 5 * time.Second) or untyped, and untyped is the interesting part: an untyped constant has a default type but adapts to the context it is used in, with arbitrary precision at compile time.
That is why const big = 1 << 62 works on any platform and why time.Second * 5 type-checks: the untyped 5 becomes a time.Duration. But a variable cannot do that: i := 5; time.Second * i fails with a mismatched types error, a compile-error question interviewers genuinely ask. iota is the const-block counter: inside a const block it starts at 0 and increments per ConstSpec line, and a line with no expression repeats the previous one, which is how enum-like blocks stay terse. Standard patterns: sequential enums (StatusPending Status = iota), skipping the zero value with _ = iota so the zero value of the type stays distinguishable as "unset", bit flags with 1 << iota, and size constants (KB = 1 << (10 * (iota + 1))).
Go has no real enum type: an iota-based type does not stop anyone writing Status(99), there is no exhaustiveness checking in switch (linters like exhaustive add it), and the compiler will not stop arithmetic on your enum. The complete idiom is therefore: a defined type, an iota const block, a String() method (usually generated with go generate plus the stringer tool: //go:generate stringer -type=Status), and optionally an IsValid() method for boundary validation on values arriving from JSON or a database.
package main
import "fmt"
type Status int
const (
StatusUnknown Status = iota // 0: zero value = "unset"
StatusPending // 1 (expression repeats)
StatusActive // 2
StatusClosed // 3
)
const (
FlagRead = 1 << iota // 1
FlagWrite // 2
FlagAdmin // 4
)
func main() {
s := StatusActive
fmt.Println(s) // 2 (write a String() method for names)
fmt.Println(FlagRead | FlagAdmin) // 5
const untyped = 5
var d = untyped * 2 // untyped adapts to context
fmt.Println(d)
}
Key Points
- Constants are compile-time, basic types only; no const slices/maps
- Untyped constants adapt to context; typed variables do not
- iota increments per line; 1 << iota builds bit flags
- Go enums need discipline: stringer, IsValid, exhaustive linting
Q20How does sync.WaitGroup coordinate goroutines, what are its classic misuse bugs, and what did WaitGroup.Go add in Go 1.25?
BasicConcurrency
Answer
sync.WaitGroup is a counter for waiting on a set of goroutines: Add(n) increments before launching work, each goroutine calls Done() (equivalent to Add(-1)) when finished, and Wait() blocks until the counter hits zero. The zero value is ready to use. Three misuse patterns account for most WaitGroup bugs in the wild, and interviewers test all of them.
First, calling Add inside the spawned goroutine instead of before the go statement: the race is that Wait can run before the goroutine has incremented, so Wait returns immediately and the program exits with work unfinished; Add must happen in the launching goroutine, before go. Second, forgetting Done on error paths: any early return that skips Done deadlocks Wait forever, which is why defer wg.Done() as the first line of the goroutine is the required idiom, not a style preference. Third, copying a WaitGroup: passing one by value gives the goroutine a copy whose Done never reaches the original; always pass *sync.WaitGroup or capture it in a closure, and go vet flags the copy.
If the counter goes negative, Done panics with "sync: negative WaitGroup counter". Reuse before Wait returns is also a documented race. Go 1.25 added the method that removes the first two bug classes entirely: wg.Go(func() { ... }) increments the counter, launches the goroutine, and guarantees the decrement when the function returns, mirroring what errgroup.Group.Go has done for years. In new codebases wg.Go is the default; mention errgroup (golang.org/x/sync/errgroup) in the same breath, since it adds what WaitGroup lacks, error propagation and context cancellation of sibling goroutines, and remains the right tool when any task failing should stop the rest.
package main
import (
"fmt"
"sync"
)
func main() {
// Classic form: Add BEFORE go, defer Done inside.
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Println("worker", id)
}(i)
}
wg.Wait()
// Go 1.25+: wg.Go handles Add/Done for you.
var wg2 sync.WaitGroup
for i := 1; i <= 3; i++ {
wg2.Go(func() {
fmt.Println("task", i)
})
}
wg2.Wait()
}
Q21What changed about for-loop variable scoping in Go 1.22, and why was the old behaviour such a famous bug source?
BasicVersion Changes
Answer
Before Go 1.22, a for loop declared its iteration variables once and updated them each iteration. Every closure created in the loop body captured the same variable, so by the time goroutines or deferred functions actually ran, they all observed the final value: launching go func() { fmt.Println(i) }() in a loop over 0..2 typically printed 3 3 3. This "loop variable capture" bug was so pervasive that go vet had a dedicated loopclosure check, code reviews demanded the i := i shadowing idiom or passing i as an argument, and the Go team measured that a large share of real-world concurrency bugs reduced to it.
Go 1.22 changed the language semantics: each iteration now gets a fresh instance of the loop variable, so closures capture per-iteration values and the same code prints 0 1 2 in some order. This was a rare backwards-incompatible language change, shipped safely through the go directive in go.mod: the new semantics apply only to packages whose go.mod declares go 1.22 or later, so old modules keep old behaviour, and the toolchain even shipped bisect tooling to find code whose behaviour changed. Interviewers use this question three ways: to check you know the change exists (still the top gotcha in legacy codebases pinned to older go directives), to have you explain the old workarounds (i := i inside the loop, or go func(i int) {...}(i)), and to check the boundary details: the change covers both three-clause for loops and range loops, and Go 1.22 also added range-over-int (for i := range 10), with range-over-function iterators following in 1.23. If you maintain a service that predates 2024, knowing that upgrading the go directive silently changes closure behaviour, almost always fixing latent bugs rather than adding them, is genuinely useful operational knowledge.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// go.mod "go 1.22"+ : prints 0,1,2 (fresh i per iteration)
// go.mod "go 1.21"- : typically printed 3,3,3
fmt.Println(i)
}()
}
wg.Wait()
for i := range 3 { // range-over-int, also Go 1.22
fmt.Println("range int:", i)
}
}
Key Points
- Go 1.22: fresh loop variable per iteration; closures capture correctly
- Gated by the go directive in go.mod, not the toolchain version
- Legacy fixes to recognise: i := i shadowing, or pass i as an argument
- Same release added range-over-int; 1.23 added range-over-func
Q22What do gofmt, go vet, staticcheck, and golangci-lint each catch, and how do Go teams wire them into CI?
BasicTooling
Answer
These four tools do different jobs and interviewers expect you to know the boundaries. gofmt mechanically formats code to the single canonical style; there are no configuration options, which killed formatting debates industry-wide, and most teams actually run gofmt -l in CI (list files needing formatting, fail if any) while developers use goimports, a superset that also manages import statements, wired into the editor on save. go vet ships with the toolchain and finds correctness bugs the compiler allows: Printf verb/argument mismatches, copying locks (copylocks), unreachable code, wrong struct tags, loop variable capture in old codebases (loopclosure), and misuse of unsafe. It runs automatically as part of go test since Go 1.10, which surprises people. staticcheck (honnef.co) is the strongest single third-party analyser: hundreds of checks (SA series for bugs like SA1029 misusing context keys or SA6002 sync.Pool of non-pointer values, S series for simplifications, ST series for style like ST1005 error string capitalisation), catching real bug classes vet does not. golangci-lint is not an analyser itself but a runner that executes dozens of linters (staticcheck, govet, errcheck, revive, gosec, exhaustive, and more) in parallel with a shared cache and one .golangci.yml config; it is the de facto CI standard, and errcheck specifically (flagging ignored error returns, like a bare defer f.Close() or an unchecked json.Unmarshal) is the one most teams consider non-negotiable. A sensible pipeline, in order: gofmt/goimports check, go vet, golangci-lint with an agreed config, go test -race, go build. Two habits worth mentioning: run linters on changed code only when adopting them in a legacy repo (golangci-lint's new-from-rev option) to avoid a thousand-issue wall, and treat every linter you enable as a team contract, an ignored, always-red linter is worse than none.
# what CI typically runs, in order
test -z "$(gofmt -l .)" # fail if any file is unformatted
go vet ./...
golangci-lint run ./... # staticcheck, errcheck, gosec, ...
go test -race -count=1 ./...
go build ./...
# .golangci.yml (minimal, opinionated)
# linters:
# enable:
# - staticcheck
# - errcheck
# - govet
# - revive
# - gosec
Key Points
- gofmt: one true style, zero config; goimports adds import management
- go vet: toolchain-shipped bug finder, auto-runs under go test
- staticcheck: deepest single analyser (SA/S/ST check series)
- golangci-lint: parallel runner + one config; errcheck is essential
Q23Show the table-driven test pattern with subtests. Why is it the dominant testing idiom in Go?
BasicTesting
Answer
Go's testing package is deliberately minimal: test files end in _test.go, test functions are func TestXxx(t *testing.T), and there are no assertion keywords, you write if got != want { t.Errorf(...) }. On top of that minimal base the community converged on table-driven tests: define a slice of anonymous-struct cases (name, inputs, expected outputs), then loop and run each case as a subtest with t.Run(tc.name, func(t *testing.T) {...}). The pattern dominates because it scales linearly: adding the tenth edge case is one struct literal, not a copied function; every case gets its own name in output (TestParse/empty_input), so failures pinpoint themselves; and go test -run 'TestParse/empty' re-runs exactly one case, which makes debugging tight.
Details that separate practised Go testers: t.Errorf records a failure and continues (you see all broken cases in one run) while t.Fatalf stops the current test, use Fatalf only when continuing is meaningless, like a failed setup; t.Helper() in shared assertion helpers makes failure line numbers point at the caller; t.Parallel() inside subtests parallelises cases, which interacts correctly with the Go 1.22+ loop variable semantics (older codebases needed tc := tc); and cleanup belongs in t.Cleanup(func(){...}) rather than defer when helpers create resources. For comparing structs and slices, the standard library answer is reflect.DeepEqual but most teams use github.com/google/go-cmp (cmp.Diff prints a readable diff of exactly what differs). Also know the surrounding flags because they come up: go test -run for filtering, -count=1 to bypass test caching, -v for verbose, and -short with testing.Short() to skip slow tests locally. Table-driven tests plus subtests is often an explicit interview task: "write tests for this function" is scored on whether this shape appears.
package price
import "testing"
func TestApplyGST(t *testing.T) {
cases := []struct {
name string
amount int64 // paise
rate int
want int64
wantErr bool
}{
{name: "standard 18%", amount: 10000, rate: 18, want: 11800},
{name: "zero amount", amount: 0, rate: 18, want: 0},
{name: "negative amount", amount: -100, rate: 18, wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := ApplyGST(tc.amount, tc.rate)
if (err != nil) != tc.wantErr {
t.Fatalf("err = %v, wantErr = %v", err, tc.wantErr)
}
if got != tc.want {
t.Errorf("ApplyGST(%d, %d) = %d, want %d", tc.amount, tc.rate, got, tc.want)
}
})
}
}
Key Points
- Cases as a slice of structs; t.Run gives each a named subtest
- t.Errorf continues, t.Fatalf stops; t.Helper fixes line numbers
- go test -run 'TestX/case_name' targets one case
- Use go-cmp's cmp.Diff for struct comparison failures
Q24Why can an interface holding a nil pointer be non-nil, and how does this cause real production bugs?
BasicInterfaces
Answer
An interface value is internally a two-word pair: a type descriptor and a data pointer. An interface equals nil only when both words are nil, no type, no value. When you store a typed nil pointer in an interface, the type word gets filled in (*MyError, say) even though the data pointer is nil, and the interface as a whole is no longer nil.
The canonical bug: a function declared to return error builds its result in a typed variable, var e *MyError; ... ; return e. When nothing went wrong e stays nil, but the return statement converts *MyError(nil) into a non-nil error interface, so the caller's if err != nil fires on success and your service starts failing healthy requests, or logging phantom errors, in production. The same trap fires with any interface, not just error: assigning a nil *bytes.Buffer to an io.Writer gives a non-nil writer that panics on use.
Fixes, in order of preference: declare the concrete-typed variable only inside the failure branch and return literal nil on success paths; or have functions return the error interface type directly rather than a concrete error pointer type; and at API boundaries, never compare err != nil against interfaces you built from concrete typed pointers without knowing this rule. For inspection, errors.As is the right tool rather than type assertions, and fmt.Printf("%T %v", err, err) is the fastest way to see the hidden type word while debugging (it prints *main.MyError <nil>, the smoking gun). Interviewers love this question because it cannot be answered by pattern matching, it requires knowing the two-word representation; the follow-up is usually "how would you detect this in a codebase", where the honest answer is discipline plus code review, since vet does not fully catch it, though staticcheck flags some shapes.
package main
import "fmt"
type MyError struct{ msg string }
func (e *MyError) Error() string { return e.msg }
func buggy() error {
var e *MyError // nil pointer
// ... success path, e never set ...
return e // interface gets type *MyError, value nil: NOT nil!
}
func fixed() error {
// return the interface nil directly on success
return nil
}
func main() {
err := buggy()
fmt.Println(err == nil) // false (!!)
fmt.Printf("%T %v\n", err, err) // *main.MyError <nil>
fmt.Println(fixed() == nil) // true
}
Q25Explain the GMP scheduler: what G, M, and P are, how work stealing operates, and what GOMAXPROCS controls.
IntermediateRuntime
Answer
The runtime scheduler multiplexes goroutines onto OS threads using three entities. G is a goroutine: stack, instruction pointer, scheduling state. M is a machine, an actual OS thread.
P is a processor, a scheduling context holding a local run queue of Gs; only an M holding a P can execute Go code, and the number of Ps equals GOMAXPROCS (defaulting to the number of CPU cores visible, with container-awareness improvements in Go 1.25 that respect cgroup CPU limits). Each P has a local run queue (up to 256 Gs) plus there is one global queue. An M runs Gs from its P's local queue; when that empties it checks the global queue, polls the netpoller for goroutines whose I/O completed, and then steals half the run queue of a random other P, which is what keeps all cores busy without a central lock becoming the bottleneck.
Two mechanisms complete the picture. Blocking: when a G makes a blocking syscall, the M blocks with it, and the P detaches and is handed to another (possibly new) M so the other goroutines keep running; network I/O avoids this entirely because the netpoller (epoll/kqueue/IOCP) parks the G without blocking any M. Preemption: since Go 1.14 the scheduler preempts asynchronously using signals (SIGURG), so a tight CPU loop with no function calls can no longer starve other goroutines, before 1.14 that was a real production failure mode. What interviewers want beyond recitation: why this design means goroutine switches are cheap (no kernel involvement in the common path), why GOMAXPROCS bounds parallelism but not concurrency (you can have a million Gs on 8 Ps), and the operational relevance: in Kubernetes with CPU limits, an inflated GOMAXPROCS causes throttling, which is exactly what the Go 1.25 container-aware default fixes (previously teams used uber-go/automaxprocs).
Key Points
- G = goroutine, M = OS thread, P = scheduling context (GOMAXPROCS of them)
- Local run queues + work stealing (steal half from a random P)
- Syscalls detach P from blocked M; netpoller parks Gs without blocking Ms
- Async preemption since 1.14; container-aware GOMAXPROCS since 1.25
Q26State the channel axioms: what happens on send/receive to nil channels and closed channels, and how do you exploit them?
IntermediateChannels
Answer
Four axioms, worth reciting exactly. One: a send to a nil channel blocks forever. Two: a receive from a nil channel blocks forever.
Three: a send to a closed channel panics. Four: a receive from a closed channel returns immediately, yielding buffered values first, then the zero value with ok=false. From these, all channel behaviour follows, and the interesting interview material is exploiting the nil cases deliberately.
Because a nil channel's cases in a select are never ready, you can disable a completed source in a fan-in loop by setting its channel variable to nil: merge two producer channels, and when ch1 delivers ok=false, set ch1 = nil; its case stops firing, the select naturally narrows to ch2, and the loop ends when both are nil. Without this trick, a closed channel would busy-spin the select delivering zero values forever, a classic bug where a merged stream floods downstream with empty records at 100% CPU. The closed-channel receive axiom powers broadcasting: close is the only channel operation observed by all current and future receivers simultaneously, which is why context cancellation is implemented as closing a channel (ctx.Done() returns it), and why signalling "stop everyone" is done with close(quit), never by sending N messages to N workers.
The panic axioms drive ownership discipline: the goroutine that owns the send side closes, receivers never close, and with multiple senders you do not close at all directly, you wrap with a sync.WaitGroup and a single closer goroutine. A follow-up worth anticipating: how do you check whether a channel is closed without receiving? You cannot, by design, any such check would race; structure ownership so the question never needs asking.
package main
import "fmt"
// fan-in that disables finished sources by nil-ing them
func merge(ch1, ch2 <-chan int, out chan<- int) {
for ch1 != nil || ch2 != nil {
select {
case v, ok := <-ch1:
if !ok {
ch1 = nil // case never fires again
continue
}
out <- v
case v, ok := <-ch2:
if !ok {
ch2 = nil
continue
}
out <- v
}
}
close(out)
}
func main() {
a, b, out := make(chan int, 2), make(chan int, 2), make(chan int)
a <- 1; a <- 2; close(a)
b <- 10; close(b)
go merge(a, b, out)
for v := range out {
fmt.Println(v)
}
}
Q27How does context.Context propagate cancellation and deadlines through a Go service, and what are the rules for context.WithValue?
IntermediateContext
Answer
context.Context threads request-scoped lifecycle through a call graph. You derive child contexts from a parent: context.WithCancel returns a context plus a cancel function; context.WithTimeout and WithDeadline add a time bound; cancelling a parent cancels every descendant, and the signal is observable two ways, the ctx.Done() channel (closed on cancellation, for use in select) and ctx.Err() (context.Canceled or context.DeadlineExceeded once done). In an HTTP service the chain starts at r.Context(), which the net/http server cancels when the client disconnects, so a handler that passes ctx into its database calls (db.QueryContext(ctx, ...)) automatically stops querying for a user who already closed the tab; whether work actually stops depends on every layer honouring ctx, context is cooperative, it never kills goroutines.
The conventions are strict and interviewers check them: ctx is the first parameter, named ctx, of any function doing I/O or capable of blocking; never store a context in a struct field (it belongs to a call, not an object, the linted exception being the http.Request pattern); always call the returned cancel function, typically defer cancel(), because forgetting it leaks the child context's timer and goroutine until the parent ends, a real memory leak vet's lostcancel check exists for. context.WithValue is the sharp edge: it is for request-scoped metadata that transits API boundaries, trace IDs, authenticated user, deadline hints for logging, never for passing function parameters or dependencies (a DB handle in a context is an interview red flag). Keys must be unexported custom types (type ctxKey struct{}) to prevent cross-package collisions, staticcheck SA1029 flags string keys. Also know the additions: context.WithCancelCause and context.Cause (Go 1.20) let you record why cancellation happened, and context.WithoutCancel (1.21) detaches background work, like audit logging, that must outlive the request.
package main
import (
"context"
"fmt"
"time"
)
type ctxKey struct{} // unexported key type: no collisions
func handle(ctx context.Context) {
ctx = context.WithValue(ctx, ctxKey{}, "req-7f3a")
ctx, cancel := context.WithTimeout(ctx, 80*time.Millisecond)
defer cancel() // ALWAYS: lostcancel leak otherwise
if err := slowDB(ctx); err != nil {
fmt.Println("request", ctx.Value(ctxKey{}), "failed:", err)
}
}
func slowDB(ctx context.Context) error {
select {
case <-time.After(200 * time.Millisecond):
return nil
case <-ctx.Done():
return ctx.Err() // context deadline exceeded
}
}
func main() { handle(context.Background()) }
Key Points
- Derivation tree: cancelling a parent cancels all descendants
- Cooperative: every blocking layer must accept and honour ctx
- defer cancel() or you leak timers (vet lostcancel)
- WithValue: request metadata only, unexported key types (SA1029)
Q28sync.Mutex, sync.RWMutex, or sync/atomic: how do you choose, and what does contention do to each?
IntermediateSynchronization
Answer
sync.Mutex is the default: Lock/Unlock around the critical section, defer mu.Unlock() immediately after locking unless the function is hot enough that you have measured the difference. It is not reentrant, a goroutine relocking a mutex it holds deadlocks, so no recursive locking patterns from Java port over. sync.RWMutex adds RLock/RUnlock: multiple readers proceed concurrently, writers get exclusivity. The interview nuance is that RWMutex is not automatically faster for read-heavy loads: its bookkeeping is heavier than Mutex, readers still contend on shared cache-line updates internally, and a waiting writer blocks new readers (writer preference prevents writer starvation).
The practical guidance: reach for RWMutex when read sections are long or genuinely concurrent-heavy (a config snapshot read by every request, rebuilt every minute); for short critical sections, benchmark before assuming, plain Mutex often wins. sync/atomic operates on single machine words without locks: atomic.AddInt64 for counters, CompareAndSwap for lock-free state machines, and since Go 1.19 the typed wrappers (atomic.Int64, atomic.Bool, atomic.Pointer[T]) which prevent the classic bug of mixing atomic and plain access to the same variable. Atomics are the right tool for counters, flags, and publish-a-pointer patterns (atomic.Pointer to an immutable config struct is a lock-free read path); they are the wrong tool the moment an invariant spans two variables, atomically updating two counters "together" is impossible with two atomic ops, that needs a mutex. Everything here is diagnosable: build with -race for correctness, and use the mutex profile (runtime.SetMutexProfileFraction plus pprof /debug/pprof/mutex) and the block profile to see contention in production rather than guessing. The strongest closing point: correctness first with the simplest primitive (Mutex), then optimise with evidence; "clever" atomic code without benchmarks is a review reject.
package main
import (
"fmt"
"sync"
"sync/atomic"
)
type Config struct{ RateLimit int }
var (
current atomic.Pointer[Config] // lock-free reads of immutable snapshot
hits atomic.Int64
mu sync.Mutex
totals = map[string]int{} // invariant spans a structure: mutex
)
func main() {
current.Store(&Config{RateLimit: 100})
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
hits.Add(1) // atomic counter
_ = current.Load().RateLimit // atomic pointer read
mu.Lock()
totals["requests"]++ // map write needs the mutex
mu.Unlock()
}()
}
wg.Wait()
fmt.Println(hits.Load(), totals["requests"]) // 50 50
}
Key Points
- Mutex is the default; it is NOT reentrant
- RWMutex helps long/parallel reads; benchmark, do not assume
- Typed atomics (atomic.Int64, atomic.Pointer[T]) since 1.19
- Multi-variable invariants require a mutex, never paired atomics
Q29What exactly is a data race in Go, how does go test -race find one, and why is a benign data race a myth?
IntermediateConcurrency
Answer
A data race is two goroutines accessing the same memory location concurrently, at least one access being a write, with no synchronisation establishing an order between them. Under the Go memory model a program with a data race has no defined behaviour: the compiler and CPU are free to reorder, cache, and tear the accesses, so racy code can read half-written values, observe writes out of order, or work perfectly in testing and corrupt data at scale. This is why "it is just a counter, a missed increment is fine" is wrong in a specific technical sense: racy code is not "slightly inaccurate", it is outside the language's guarantees entirely, and real crashes (corrupted slice headers, torn interface values leading to segfaults) come from exactly this reasoning.
The race detector, enabled with go test -race or go build -race, instruments every memory access and tracks happens-before relationships using a vector-clock algorithm (based on ThreadSanitizer). When it observes conflicting unordered accesses, it prints both stack traces, the goroutines' creation sites, and the address, then fails. Two properties to state precisely in interviews: it reports no false positives (a report is a real race in that execution), but it can miss races that the test run's interleaving never exercised, so a clean -race run is evidence, not proof, and race coverage is only as good as the concurrency your tests actually create.
Cost is roughly 5-10x CPU and 5-10x memory, so the standard practice is -race on all CI test runs and optionally on a small canary slice of production traffic, not the whole fleet. Common findings in real codebases: unguarded maps (often crashing first via the runtime's separate concurrent-map detector), lazily initialised singletons without sync.Once, tests sharing package-level state under t.Parallel, and racy reads of a config struct being hot-reloaded. The fix vocabulary: channels, sync.Mutex, sync/atomic, or restructuring so only one goroutine owns the data ("do not communicate by sharing memory; share memory by communicating").
package main
import (
"fmt"
"sync"
)
func main() {
n := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
n++ // RACE: unsynchronised read-modify-write
}()
}
wg.Wait()
fmt.Println(n) // some value <= 1000; run with: go run -race .
// The race detector prints both stacks:
// WARNING: DATA RACE
// Read at 0x00c0000140a8 by goroutine 8 ...
// Previous write at 0x00c0000140a8 by goroutine 7 ...
}
Q30How do errors.Is, errors.As, and errors.Join work, and when do you choose sentinel errors versus custom error types?
IntermediateError Handling
Answer
errors.Is(err, target) reports whether target appears anywhere in err's wrap chain, unwrapping through every error created with fmt.Errorf's %w verb (or a custom Unwrap() error method). It replaces err == target, which fails the moment anything wraps the error. errors.As(err, &target) walks the same chain looking for an error assignable to target's concrete type, filling target on success; it replaces type assertions like err.(*PgError) which similarly break under wrapping. errors.Join(errs...) (Go 1.20) combines multiple errors into one whose Unwrap() []error exposes all branches; Is and As traverse the resulting tree, and the standard use cases are collecting independent validation failures and combining a primary error with a deferred Close error.
Design choice: use a sentinel (var ErrNotFound = errors.New("not found")) when callers only need to recognise the condition, it is the lightest contract, and the standard library is full of them (sql.ErrNoRows, io.EOF, os.ErrNotExist, fs.ErrNotExist). Use a custom type when callers need structured data off the error: a *PgError with a Code field, a *ValidationError carrying the field list, an *HTTPError with a status. The trade-off to articulate: both sentinels and exported types become API surface you must maintain forever; every error you make inspectable is a contract, so export deliberately and keep most errors opaque strings with context.
Two production notes that elevate an answer: gRPC and HTTP boundaries do not transport Go error chains, so map errors to status codes/error codes at the edge rather than expecting errors.Is to work across the wire; and when wrapping with %w exposes internals you do not want callers coupling to, deliberately break the chain with %v. Since Go 1.20 fmt.Errorf also accepts multiple %w verbs, wrapping several errors in one formatted message.
package main
import (
"errors"
"fmt"
)
var ErrInsufficientBalance = errors.New("insufficient balance")
type DeclineError struct{ Code string }
func (e *DeclineError) Error() string { return "declined: " + e.Code }
func charge() error {
base := &DeclineError{Code: "51"}
return fmt.Errorf("charging card: %w", base)
}
func main() {
err := charge()
var de *DeclineError
if errors.As(err, &de) { // finds the typed error through the wrap
fmt.Println("decline code:", de.Code) // 51
}
joined := errors.Join(err, ErrInsufficientBalance)
fmt.Println(errors.Is(joined, ErrInsufficientBalance)) // true
}
Key Points
- Is = identity through the chain; As = typed extraction through the chain
- Join (1.20) builds error trees; Is/As traverse all branches
- Sentinels for recognition, custom types for structured data
- Error chains do not cross RPC boundaries; map to codes at the edge
Q31How do generics work in Go: type parameters, constraints, and where should you actually use them (and not)?
IntermediateGenerics
Answer
Since Go 1.18, functions and types can take type parameters in square brackets: func Map[T, U any](xs []T, f func(T) U) []U. A constraint is an interface that bounds the type parameter: any allows everything; comparable permits == and map-key usage; constraints can embed type sets with union elements, type Number interface { ~int | ~int64 | ~float64 }, where the tilde means "any type whose underlying type is int", admitting named types like type Paise int64. Instantiation is usually inferred from arguments; when inference fails you write it explicitly: Map[string, int](names, len).
The compiler implements generics with GC-shape stenciling plus dictionaries: types with the same memory shape (all pointers, for instance) share one compiled instantiation with a runtime dictionary for type-specific operations, so generic code is mostly monomorphised-fast but not always identical to hand-specialised code, worth benchmarking on genuinely hot paths, and occasionally an interface parameter is just as fast and simpler. Go 1.21 shipped the fruits of generics in the standard library: slices (Contains, Sort, BinarySearch, Clone), maps (Keys, Values iterators from 1.23), and cmp; Go 1.24 completed generic type aliases. When to use generics, the part interviewers actually score: type-parameterised containers and algorithms (a Set[T comparable], an LRU cache, Map/Filter/Reduce helpers), and eliminating duplicated per-type copies of identical logic.
When not to: anywhere interfaces already model the behaviour, io.Reader code gains nothing from [R io.Reader]; single-use abstractions; and public APIs where the extra ceremony taxes every caller. The idiomatic instinct is restraint, generics removed the need for interface{}-plus-type-assertions and code generation for containers, but Go code remains concrete-first. A good closing note: methods cannot take their own type parameters (a known limitation), and constraint interfaces containing type unions cannot be used as ordinary interface values.
package main
import (
"cmp"
"fmt"
"slices"
)
type Number interface {
~int | ~int64 | ~float64 // ~ = underlying type
}
func Sum[T Number](xs []T) T {
var total T
for _, x := range xs {
total += x
}
return total
}
type Paise int64 // named type, admitted via ~int64
func main() {
fmt.Println(Sum([]Paise{100, 250, 399})) // 749
users := []struct {
Name string
Age int
}{{"Asha", 31}, {"Ravi", 27}}
slices.SortFunc(users, func(a, b struct {
Name string
Age int
}) int {
return cmp.Compare(a.Age, b.Age)
})
fmt.Println(users[0].Name) // Ravi
}
Key Points
- Constraints are interfaces; ~T admits named types by underlying type
- comparable enables == and map keys in generic code
- Implementation: shape stenciling + dictionaries, benchmark hot paths
- Use for containers/algorithms; prefer plain interfaces for behaviour
Q32Implement a bounded worker pool that processes jobs from a channel and stops cleanly on context cancellation.
IntermediateConcurrency Patterns
Answer
The worker pool is the most-asked live-coding exercise in Go interviews because it composes everything: goroutines, channels, WaitGroup, context, and close discipline. The canonical shape: a jobs channel (buffered to absorb bursts and provide backpressure when full), N worker goroutines ranging over it, a results channel, and a closer goroutine that waits for all workers then closes results so the consumer's range terminates. The ownership rules that make it correct: the producer alone closes jobs when done submitting; workers never close anything they receive from; results is closed exactly once, by the goroutine that knows all senders finished (after wg.Wait()).
Cancellation weaves in as a select inside the worker loop, receive a job or observe ctx.Done(), and, importantly, also on the submit side, because sending into a full jobs channel must abort if the context dies, otherwise the producer goroutine leaks blocked on send forever, a subtle leak most candidates miss. Sizing N: for CPU-bound work, runtime.GOMAXPROCS(0) workers is the ceiling worth having; for I/O-bound work (HTTP calls, DB queries), N reflects the downstream's capacity, and is often better expressed as a semaphore (golang.org/x/sync/semaphore or a buffered channel of struct{}) guarding an external rate. Error handling choices to narrate: a result struct carrying (value, err) per job keeps the pool generic; errgroup.Group with SetLimit(n) is the modern shortcut when "first error cancels everything" is the desired semantics, and interviewers increasingly accept naming it as the production answer while still wanting the raw version written out. Extensions that earn points if time remains: graceful drain versus hard stop (stop pulling new jobs but finish in-flight ones), panic isolation per job with a deferred recover, and per-job timeouts via context.WithTimeout derived inside the worker.
package main
import (
"context"
"fmt"
"sync"
"time"
)
type Result struct {
Job int
Err error
}
func pool(ctx context.Context, jobs <-chan int, n int) <-chan Result {
results := make(chan Result)
var wg sync.WaitGroup
for w := 0; w < n; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case j, ok := <-jobs:
if !ok {
return // producer closed jobs
}
results <- Result{Job: j * j}
case <-ctx.Done():
return // cancelled: stop pulling work
}
}
}()
}
go func() { wg.Wait(); close(results) }() // single closer
return results
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
jobs := make(chan int, 8)
go func() {
defer close(jobs) // producer owns the close
for i := 1; i <= 5; i++ {
select {
case jobs <- i:
case <-ctx.Done():
return // do not leak on a full channel
}
}
}()
for r := range pool(ctx, jobs, 3) {
fmt.Println(r.Job)
}
}
Q33How do you shut down a Go HTTP server gracefully using http.Server.Shutdown and signal.NotifyContext?
IntermediateHTTP
Answer
Graceful shutdown means: stop accepting new connections, let in-flight requests finish within a budget, then exit. Kubernetes makes this non-optional, on pod termination the kubelet sends SIGTERM, waits terminationGracePeriodSeconds (default 30), then SIGKILLs, so a Go service that ignores SIGTERM drops every in-flight request on deploys, which shows up as a spike of 502s from the load balancer on every rollout. The building blocks: signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) (Go 1.16) returns a context cancelled on the first signal, replacing the older manual signal.Notify channel dance. http.Server.Shutdown(ctx) closes listeners (so ListenAndServe returns http.ErrServerClosed, which you must treat as normal, not an error), waits for active connections to go idle, and respects ctx's deadline, returning context.DeadlineExceeded if stragglers exceed the budget.
The standard shape: run srv.ListenAndServe() in a goroutine; block on <-ctx.Done(); then call Shutdown with a fresh timeout context (10-25 seconds, comfortably inside the pod grace period). Details that separate a working answer from a production one: Server.Close() is the abrupt sibling that drops connections, only for the post-timeout fallback; hijacked connections (WebSockets) are not waited on by Shutdown, you must track and close them via srv.RegisterOnShutdown; background workers need the same ctx so the whole process quiesces together; and readiness probes should start failing the moment shutdown begins so the load balancer stops routing new traffic, in Kubernetes this often means a small preStop sleep so endpoint removal propagates before the listener closes. Also set the server's other timeouts (ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout) explicitly; the zero values mean no timeout, and an http.Server with no ReadHeaderTimeout is an open invitation to slowloris connection exhaustion.
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(),
os.Interrupt, syscall.SIGTERM)
defer stop()
srv := &http.Server{
Addr: ":8080",
ReadHeaderTimeout: 5 * time.Second, // slowloris defence
IdleTimeout: 60 * time.Second,
}
go func() {
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
<-ctx.Done() // SIGTERM from kubelet, or Ctrl+C locally
log.Println("draining...")
shutCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil {
log.Printf("forced exit: %v", err)
}
}
Key Points
- signal.NotifyContext turns SIGTERM into context cancellation
- Shutdown stops listeners, drains in-flight, honours its ctx deadline
- http.ErrServerClosed from ListenAndServe is expected, not an error
- Zero-value server timeouts mean none: set ReadHeaderTimeout explicitly
Q34What did the Go 1.22 net/http.ServeMux routing enhancements add, and how does pattern precedence work?
IntermediateHTTP
Answer
Until Go 1.22, the standard ServeMux matched only fixed path prefixes: no methods, no path parameters, which is why gorilla/mux, chi, gin, and echo dominated routing. Go 1.22 rewrote ServeMux patterns to support methods and wildcards natively: "GET /users/{id}" matches GET requests only and binds the segment to id, retrieved in the handler with r.PathValue("id"). A trailing "{path...}" wildcard captures the rest of the path across segments, and the special "{$}" matches only the exact path, solving the old footgun where the pattern "/" matched every URL: "GET /{$}" is now the correct way to route only the homepage.
Method patterns match exactly, except GET also matches HEAD; requests matching a path with the wrong method get an automatic 405 Method Not Allowed with a correct Allow header, which hand-rolled routing rarely bothered with. Precedence is by specificity, not registration order: the most specific matching pattern wins, "GET /posts/latest" beats "GET /posts/{id}", and if two registered patterns overlap with neither more specific (a conflict like "GET /posts/{id}" versus "/posts/latest" registered without method), ServeMux panics at registration time, surfacing ambiguity at startup rather than serving surprising matches. Interview angles: this covers a large share of what teams imported chi for, so "stdlib or framework" answers changed, the mux still lacks middleware chaining helpers and route groups, but plain func(next http.Handler) http.Handler middleware composes fine by hand; PathValue returns "" for absent names, so validation stays on you; and patterns with a host prefix ("api.example.com/") still work as before. Knowing that HandleFunc("GET /x", h) panics on a malformed pattern at startup, and being able to state the precedence rule in one sentence, is the depth bar here.
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "home only, not a catch-all")
})
mux.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "order:", r.PathValue("id"))
})
// more specific literal wins over the {id} wildcard
mux.HandleFunc("GET /orders/latest", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "latest order")
})
mux.HandleFunc("DELETE /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
// GET /orders/7 -> order: 7 ; POST /orders/7 -> 405 + Allow header
log.Fatal(http.ListenAndServe(":8080", mux))
}
Key Points
- "GET /users/{id}" + r.PathValue: methods and params in stdlib
- {$} = exact match; {path...} = multi-segment tail
- Most-specific pattern wins; ambiguous overlaps panic at registration
- Wrong method on a known path returns 405 with Allow automatically
Q35How does encoding/json really behave: struct tags, omitempty, unexported fields, number decoding, and custom marshalling?
IntermediateSerialization
Answer
encoding/json marshals exported struct fields only; unexported fields are silently skipped, so a struct of lowercase fields serialises to {} with no error, a bug every Go developer ships once. Field names map through struct tags: `json:"user_id"` renames, `json:"-"` excludes, `json:",omitempty"` drops the field when it holds its type's empty value (zero number, empty string, nil, empty slice/map, false). The omitempty trap to name: it cannot distinguish "absent" from "legitimately zero", a price of 0 disappears from output; the fixes are pointer fields (*int is nil when absent, present-and-zero otherwise) or a wrapper type.
On decoding, Unmarshal is permissive by design: unknown JSON keys are ignored silently, missing keys leave zero values, and JSON numbers decode into interface{} as float64, which corrupts int64 IDs above 2^53, the classic "why did my Snowflake IDs change" bug; the remedies are decoding into concrete struct types, json.Number, or a json.Decoder with UseNumber(). Decoder.DisallowUnknownFields() turns unknown keys into errors, worth enabling on strict API boundaries. Case-insensitive field matching is another surprise: {"ID":1} fills a field tagged id.
Custom behaviour hooks: implement MarshalJSON() ([]byte, error) and UnmarshalJSON([]byte) error, the standard route for money types, custom time formats (wrapping time.Time because its default is RFC 3339), and enums serialised as strings; a subtle bug is defining MarshalJSON on a value receiver but marshalling a pointer-free struct that embeds it, or recursing infinitely by calling json.Marshal on the same type inside its own MarshalJSON (escape via a type alias). Worth one sentence in 2026 interviews: encoding/json/v2 has been available as an experiment (GOEXPERIMENT=jsonv2, Go 1.25) with much higher performance and stricter, saner defaults, but encoding/json remains the production standard until v2 graduates.
package main
import (
"bytes"
"encoding/json"
"fmt"
)
type Order struct {
ID int64 `json:"id"`
Note string `json:"note,omitempty"`
Discount *int `json:"discount,omitempty"` // pointer: 0 stays visible
internal string // unexported: silently skipped
}
func main() {
zero := 0
b, _ := json.Marshal(Order{ID: 9, Discount: &zero})
fmt.Println(string(b)) // {"id":9,"discount":0}
// int64 precision: decode with UseNumber, not interface{}
dec := json.NewDecoder(bytes.NewReader([]byte(`{"id":9007199254740993}`)))
dec.UseNumber()
var m map[string]any
dec.Decode(&m)
id, _ := m["id"].(json.Number).Int64()
fmt.Println(id) // 9007199254740993, intact
// strict boundary
dec2 := json.NewDecoder(bytes.NewReader([]byte(`{"idd":1}`)))
dec2.DisallowUnknownFields()
var o Order
fmt.Println(dec2.Decode(&o)) // json: unknown field "idd"
}
Q36What causes goroutine leaks, and how do you find them in a running service?
IntermediateConcurrency
Answer
A goroutine leaks when it blocks forever with no path to completion: the memory (stack plus everything its closure references) is never reclaimed because the runtime cannot know it is dead. The recurring causes are worth listing explicitly because interviewers ask for them: sending on a channel no one will receive from (a worker writing results after the consumer bailed early on the first error); receiving from a channel no one will send on or close; the pre-1.23 time.After pattern in loops holding timers alive; forgetting to call the cancel function from context.WithTimeout so the internal goroutine and timer persist; an http.Response body never closed keeping connection-handling goroutines around; and shutdown paths that stop the producer but never close the channel workers are ranging over. The signature is monotonic growth of the goroutine count with traffic, visible as slow memory growth that looks like a heap leak but is not.
Detection tooling, in the order you would actually use it: the pprof goroutine profile, curl http://host:6060/debug/pprof/goroutine?debug=1 groups live goroutines by identical stacks with counts, so a leak reads as "48,213 goroutines parked in chan send at worker.go:87", usually diagnosis in one look; debug=2 dumps full stacks including how long each has been blocked (e.g. "chan receive, 43 minutes"); runtime.NumGoroutine() exported as a metric gives you the alertable trend line; and in tests, goleak (go.uber.org/goleak) fails any test that exits with unexpected goroutines still alive, which catches leaks at review time instead of on-call time. Prevention distils to three disciplines: every goroutine you start must have a known termination path you can state ("exits when jobs closes or ctx cancels"); prefer passing ctx and selecting on ctx.Done() around every blocking channel operation; and buffered channels of size 1 for single-result handoffs so an abandoned sender can complete its send and die instead of blocking forever.
package main
import (
"context"
"fmt"
"runtime"
"time"
)
// LEAKS: if the caller times out first, the send blocks forever
func leaky() <-chan int {
ch := make(chan int) // unbuffered
go func() {
time.Sleep(50 * time.Millisecond)
ch <- 42 // nobody left to receive
}()
return ch
}
// FIXED: buffer of 1 lets the sender finish and exit regardless
func fixed(ctx context.Context) <-chan int {
ch := make(chan int, 1)
go func() {
time.Sleep(50 * time.Millisecond)
select {
case ch <- 42:
case <-ctx.Done():
}
}()
return ch
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
select {
case v := <-fixed(ctx):
fmt.Println(v)
case <-ctx.Done():
fmt.Println("timed out")
}
time.Sleep(100 * time.Millisecond)
fmt.Println("goroutines:", runtime.NumGoroutine())
}
Key Points
- Leak = goroutine blocked forever; stack + captured memory never freed
- /debug/pprof/goroutine?debug=1 groups leaks by stack with counts
- Alert on runtime.NumGoroutine trend; use goleak in tests
- Buffer-of-1 result channels let abandoned senders finish and exit
Q37How do sync.Once, sync.OnceFunc, and sync.OnceValue handle lazy initialisation, and what deadlock should you know about?
IntermediateSynchronization
Answer
sync.Once guarantees a function runs exactly once across goroutines: once.Do(f) runs f the first time, and every other caller blocks until that first execution completes, then returns without running f. That blocking guarantee matters: Do does not merely skip subsequent calls, it ensures no caller proceeds until initialisation finished, which is exactly the semantics a lazily built singleton needs and exactly what the naive if instance == nil { instance = build() } double-checked lock gets wrong in the presence of the memory model. Details with interview mileage: the once-ness belongs to the Once instance, not the function, one Once running f then g runs only f; if f panics, Do considers it done and never re-runs, so a failed initialisation stays failed, meaning error-returning init needs you to capture the error alongside the value and re-check it per call (or deliberately use a retryable wrapper); and calling once.Do recursively from inside f deadlocks, since the outer Do holds the completion the inner one waits for.
Go 1.21 added the ergonomic forms that now dominate new code: sync.OnceFunc(f) returns a function that runs f once; sync.OnceValue(f) returns a getter that computes and caches f's single result, giving you a lazily initialised package-level value without a struct and mutex ceremony (var pool = sync.OnceValue(buildPool), then pool() everywhere); sync.OnceValues does the same for two results, tailor-made for (T, error) constructors. Contrast with the alternatives to complete the answer: package init() runs eagerly at import time whether or not the value is used and cannot return errors; plain package-level var initialisers share that eagerness; atomic.Pointer publishing is for values replaced repeatedly, not once. And the panic-latch behaviour of OnceValues holding an error means callers see the same error forever, fine for config, wrong for a flaky network resource, where you want a retry-with-backoff pattern instead.
package main
import (
"fmt"
"sync"
)
type DB struct{ dsn string }
func connect() (*DB, error) {
fmt.Println("connecting once...")
return &DB{dsn: "postgres://..."}, nil
}
// Go 1.21+: lazy, concurrent-safe, no struct ceremony
var getDB = sync.OnceValues(connect)
func main() {
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
db, err := getDB() // all callers get the same result
if err == nil {
_ = db
}
}()
}
wg.Wait()
// output contains "connecting once..." exactly one time
}
Key Points
- Do blocks other callers until the first run completes
- Panic inside f counts as done; failed init stays failed
- OnceValue/OnceValues (1.21) replace the struct+mutex singleton
- Recursive once.Do deadlocks; init() is the eager alternative
Q38Show a slice aliasing bug caused by append, and explain how the three-index slice expression and slices.Clone prevent it.
IntermediateData Structures
Answer
The bug class: two slices share a backing array, and an append through one overwrites data the other still reads. Concretely, take a := []int{1,2,3,4,5} and b := a[:2]. b has length 2 but capacity 5, so append(b, 99) sees spare capacity, writes 99 into the shared backing array, and silently turns a into [1,2,99,4,5]. No panic, no race detector warning (single goroutine), just corrupted data discovered far from the cause.
The same shape hides in function boundaries: a function that receives a slice, appends to it, and returns it may or may not have clobbered the caller's memory depending on whether the append reallocated, which is why functions should treat received slices as read-only or document ownership transfer. The surgical fix is the full slice expression s[low:high:max], where max caps the new slice's capacity at max-low: b := a[0:2:2] has capacity 2, so any append must allocate a fresh backing array, guaranteeing divergence. This is the standard idiom when handing sub-slices across API boundaries.
The blunt fix is copying: slices.Clone(s) (Go 1.21) or the older append([]int(nil), s...). Related leaks to mention while you are here: sub-slicing a huge buffer for a tiny token (header := buf[:16]) pins the entire buffer in memory for the GC, clone small windows out of big buffers; bytes.Split and friends return sub-slices of the input for the same reason; and slices.Delete and compaction idioms leave stale elements past the new length that keep pointers alive, historically you zeroed them manually, and recent versions do this clearing in the slices package functions themselves. A strong finish is stating the mental model: append may or may not allocate, so never rely on either behaviour, control it explicitly with capacity or cloning.
package main
import (
"fmt"
"slices"
)
func main() {
a := []int{1, 2, 3, 4, 5}
b := a[:2] // len 2, cap 5: shares backing array
b = append(b, 99) // fits in spare capacity...
fmt.Println(a) // [1 2 99 4 5] a is corrupted!
a = []int{1, 2, 3, 4, 5}
c := a[0:2:2] // full slice expr: cap forced to 2
c = append(c, 99) // must reallocate: a untouched
fmt.Println(a, c) // [1 2 3 4 5] [1 2 99]
d := slices.Clone(a[:2]) // independent copy
d = append(d, 77)
fmt.Println(a, d) // [1 2 3 4 5] [1 2 77]
}
Q39What is escape analysis in Go, how do you read -gcflags=-m output, and when does it actually matter?
IntermediatePerformance
Answer
Go allocates on the stack when the compiler can prove a value's lifetime is bounded by its function, and on the heap when it cannot; the proof is escape analysis, run at compile time. Stack allocation is nearly free (a pointer bump, freed wholesale on return); heap allocation costs the allocator plus future GC work, so allocation-heavy hot paths are usually GC-bound, and reducing escapes is the first lever. You inspect decisions with go build -gcflags=-m (double it, -m -m, for reasoning): lines like "moved to heap: x" or "&x escapes to heap" or "leaking param: s" tell you what escaped and often why.
The canonical causes to know cold: returning a pointer to a local (must escape, the frame dies); storing a value into an interface (fmt.Println(x) boxes x, which is why fmt calls litter benchmarks with allocations); capturing a variable by reference in a closure that outlives the function; sending pointers on channels; slices that grow beyond a compile-time-known size; and calling methods through interfaces where the compiler cannot devirtualise. Two corrections to popular myths earn credit: "new always heap-allocates" is false, new(T) stays on the stack if it does not escape; and pointers do not inherently mean heap, passing a pointer down (where the callee does not retain it) frequently stays stack-allocated since the analysis is interprocedural for small functions that get inlined, which links escape analysis to inlining, visible in the same -m output ("can inline f"). When does this matter?
Only on measured hot paths: the workflow is profile first (pprof alloc_objects), then check escapes on the offending function, then restructure, pass values instead of interfaces, reuse buffers across calls, pre-size slices, avoid capturing loop state in closures. Reciting escape rules without a profile is premature optimisation, and saying exactly that, then demonstrating the workflow, is the senior answer. Benchmarks report allocations via b.ReportAllocs(), tying the loop together.
package main
import "fmt"
type Point struct{ X, Y int }
// escapes: pointer to local returned, frame outlived
func NewPoint() *Point {
p := Point{1, 2}
return &p // build output: moved to heap: p
}
// stays on stack: value returned by copy
func MakePoint() Point {
return Point{1, 2}
}
func main() {
p := NewPoint()
q := MakePoint()
// interface boxing: q escapes into the fmt call
fmt.Println(p, q) // ... argument escapes to heap
}
// Inspect with:
// go build -gcflags='-m' ./...
// go build -gcflags='-m -m' ./... (full reasoning)
// Benchmark allocations with:
// func BenchmarkX(b *testing.B) { b.ReportAllocs(); ... }
Key Points
- Stack if lifetime provable, heap otherwise; decided at compile time
- go build -gcflags=-m prints escape and inlining decisions
- Interface boxing, returned pointers, and closures are top causes
- Profile first; escape tuning is for measured hot paths only
Q40Why is a bare `defer f.Close()` a bug for writable files, and what is the correct pattern for surfacing Close errors?
IntermediateError Handling
Answer
For read-only files, ignoring Close's error is acceptable and conventional. For writes, it is data loss waiting to happen: operating systems buffer writes, and the write syscall succeeding only means bytes reached kernel buffers. Errors from a full disk (ENOSPC), a broken NFS mount, or a vanished USB device can surface only at Close (or fsync), so a function that writes a file, checks every Write, and then does a bare defer f.Close() can report success while the file is truncated garbage. errcheck flags the bare form for exactly this reason.
The correct pattern uses the fact that deferred closures can write named return values: declare (err error) as a named result, and in the defer, capture Close's error into err if err is not already set (or join both with errors.Join so neither is lost). For maximum durability, critical writers (databases, WAL implementations) call f.Sync() before Close to force bytes to stable storage, Close alone does not fsync. The same close-error discipline applies beyond files, and naming these earns depth points: gzip.Writer and other compressing writers flush their final block in Close, so ignoring that error yields corrupt archives; bufio.Writer needs an explicit Flush with its error checked, it has no Close; database transactions use the same shape with a deferred Rollback that is a no-op after Commit; and http.Response.Body must always be closed (and, in practice, drained with io.Copy(io.Discard, resp.Body) when you finish early) to allow the underlying TCP connection to be reused by the transport's keep-alive pool; skipping this bleeds connections and shows up as ever-growing TIME_WAIT counts and new-connection latency. A tidy summary sentence for interviews: reads may ignore Close, writes must not, and the deferred-closure-plus-named-return idiom is how Go expresses "both the body and the cleanup can fail".
package main
import (
"errors"
"fmt"
"os"
)
func writeReport(path string, data []byte) (err error) {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create %s: %w", path, err)
}
defer func() {
// Close error must not be lost, join it with any write error
if cerr := f.Close(); cerr != nil {
err = errors.Join(err, fmt.Errorf("close %s: %w", path, cerr))
}
}()
if _, err := f.Write(data); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return f.Sync() // durability: flush kernel buffers to disk
}
func main() {
if err := writeReport("/tmp/out.txt", []byte("hello")); err != nil {
fmt.Println("report failed:", err)
}
}
Key Points
- Write errors can surface only at Close (ENOSPC, network FS)
- Named return + deferred closure captures Close errors
- f.Sync() before Close for durability; Close does not fsync
- Same discipline: gzip.Writer.Close, bufio.Flush, resp.Body drain+close
Q41How are interface values represented at runtime (itab), and what does dynamic dispatch through an interface cost?
IntermediateInternals
Answer
A non-empty interface value is two words: a pointer to an itab (interface table) and a pointer to the data. The itab is built once per (interface type, concrete type) pair and cached; it holds the concrete type's descriptor plus a method table, the resolved function pointers for exactly the interface's methods in order. Calling iface.Method() loads the function pointer from the itab and calls it with the data pointer as the receiver: one extra indirection versus a direct call.
The empty interface any needs no method table, so it is (type descriptor, data pointer) instead, which is why conversion between any and a non-empty interface involves an itab lookup, and why type switches on any are cheap type-word comparisons. Costs to name concretely: the indirect call itself is small, but it blocks inlining, and losing inlining loses the follow-on optimisations (escape analysis, constant folding across the call), which is the real cost on hot paths; storing a non-pointer value into an interface generally heap-allocates a copy (boxing), so a loop converting ints to any allocates per iteration, exactly what you see in naive fmt-heavy or reflection-heavy code; the compiler devirtualises when it can prove the concrete type (increasingly aggressive in recent versions, including PGO-driven devirtualisation of hot interface calls since Go 1.21). Type assertions read the type word: v, ok := i.(T) compares against T's descriptor; the single-result form panics on mismatch ("interface conversion: interface {} is string, not int"), so the comma-ok form is the production default. This model also explains the typed-nil trap from the basic section (type word set, data nil) and why interface equality compares both words, two interfaces are equal when their dynamic types and values match, and comparing interfaces holding uncomparable dynamic types (slices, maps, functions) panics at runtime with "comparing uncomparable type", a legal-compile runtime-crash pairing interviewers like to spring.
Key Points
- Non-empty interface = (itab pointer, data pointer); any = (type, data)
- itab caches per (interface, concrete) pair with resolved method table
- Real dispatch cost is lost inlining, not the indirect call itself
- Boxing non-pointer values allocates; PGO can devirtualise hot calls
Q42How do you test HTTP handlers and clients in Go using httptest, and where do t.Parallel and interface seams fit?
IntermediateTesting
Answer
net/http/httptest covers both directions without opening real ports arbitrarily. Testing a handler: build a request with httptest.NewRequest (no error to check, unlike http.NewRequest), a recorder with httptest.NewRecorder(), call handler.ServeHTTP(rec, req) directly, then assert on rec.Code, rec.Body, and headers via rec.Result(). This runs the mux and middleware stack in-process with zero network.
Testing a client: httptest.NewServer(handler) starts a real HTTP server on a random loopback port and gives you srv.URL to point your client at; defer srv.Close(). This is the standard way to test retry logic, timeout behaviour (make the fake handler sleep), and error-path parsing against controlled responses, including making the fake return 429s or malformed JSON, cases you cannot conjure against a real dependency. NewTLSServer does the same over TLS with a client preconfigured to trust its certificate (srv.Client()).
For dependencies that are not HTTP, the Go answer is interface seams rather than monkey-patching: the code under test depends on a small consumer-defined interface (Charger, UserStore), production wires the real client, tests wire a hand-written fake, often just a struct with function fields so each test customises behaviour inline without a mocking framework; gomock and moq exist, but hand-rolled fakes are the default idiom and interviewers often prefer seeing them. t.Parallel() marks a test to run concurrently with other parallel tests; it interacts with two things worth stating: package-level shared state becomes a race (run -race in CI to catch it), and t.Setenv refuses to run in parallel tests precisely because environment variables are process-global. Round out with t.Cleanup for teardown that survives t.Fatal, TestMain(m *testing.M) for suite-level setup, and go test -run 'TestCheckout/insufficient_funds' for surgical reruns; knowing httptest means never writing a test that hits a real external API, which is the actual bar being checked.
package pay
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHealthHandler(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
HealthHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), `"ok"`) {
t.Errorf("body = %q", rec.Body.String())
}
}
func TestClientRetriesOn503(t *testing.T) {
t.Parallel()
calls := 0
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
calls++
if calls == 1 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
fmt.Fprint(w, `{"status":"settled"}`)
}))
defer srv.Close()
got, err := NewClient(srv.URL).SettlementStatus("txn_1")
if err != nil || got != "settled" || calls != 2 {
t.Fatalf("got %q err %v calls %d", got, err, calls)
}
}
Key Points
- NewRequest + NewRecorder tests handlers with zero network
- httptest.NewServer gives a real URL for exercising client retry/timeout paths
- Hand-written fakes behind small interfaces beat mocking frameworks
- t.Parallel + -race; t.Setenv is incompatible with parallel tests
Q43How do you write trustworthy benchmarks with testing.B, what did b.Loop change in Go 1.24, and where does benchstat fit?
IntermediatePerformance
Answer
Benchmarks live beside tests as func BenchmarkXxx(b *testing.B) and run with go test -bench=. -benchmem (benchmarks do not run by default). The classic shape loops for i := 0; i < b.N; i++ with the framework choosing b.N until the timing is statistically meaningful; expensive setup goes before b.ResetTimer() so it is excluded. The classic shape also has classic bugs: the compiler can dead-code-eliminate a computation whose result is unused, or hoist a loop-invariant call, producing absurd sub-nanosecond "results"; folklore fixes were assigning to a package-level sink variable or using runtime.KeepAlive.
Go 1.24's b.Loop() addresses this class structurally: for b.Loop() { ... } replaces the b.N loop, keeps arguments and results of calls inside the loop alive so the compiler cannot elide the work, and excludes setup before the loop automatically, making ResetTimer mostly unnecessary; in new code b.Loop is simply the correct default. Reporting: -benchmem (or b.ReportAllocs()) adds allocations per operation and bytes per operation, often the metric that matters more than nanoseconds since allocations translate to GC pressure; b.SetBytes(n) yields MB/s for throughput benchmarks; b.RunParallel exercises contention. Then the discipline layer, which is where senior candidates differentiate: single benchmark runs lie, thermal throttling, other processes, and scheduler noise produce variance, so the standard workflow is go test -bench=X -count=10 saved to files before and after a change, compared with benchstat old.txt new.txt, which reports the delta with a p-value and flags statistically insignificant differences, protecting you from shipping "optimisations" that are noise.
Also worth naming: pin the machine (a quiet CI runner or a dedicated box), compare like-for-like GOMAXPROCS, and for microbenchmarks under ~1ns, distrust everything and benchmark at a higher level. Interviewers frequently show a benchmark whose result is optimised away and ask what is wrong; recognising elision instantly is the pass signal.
package cache
import "testing"
func BenchmarkGetOld(b *testing.B) {
c := buildCache(10000) // setup
b.ResetTimer() // exclude it (classic style)
for i := 0; i < b.N; i++ {
_ = c.Get("key42") // risk: result unused, may be elided
}
}
func BenchmarkGet(b *testing.B) {
c := buildCache(10000) // setup auto-excluded before Loop
b.ReportAllocs()
for b.Loop() { // Go 1.24+: work kept alive, no sink needed
c.Get("key42")
}
}
// Workflow:
// go test -bench=Get -benchmem -count=10 > old.txt
// ... make the change ...
// go test -bench=Get -benchmem -count=10 > new.txt
// benchstat old.txt new.txt
Key Points
- -bench=. -benchmem; allocations/op often matter more than ns/op
- b.Loop (1.24) prevents dead-code elimination and excludes setup
- count=10 + benchstat separates real wins from noise (p-values)
- b.RunParallel for contention; b.SetBytes for throughput MB/s
Q44How do you profile a live Go service with pprof: which profiles exist, how do you capture them safely in production, and how do you read them?
IntermediateObservability
Answer
Importing net/http/pprof (blank import) registers handlers under /debug/pprof/ on the default mux; production services expose them on a separate internal port so profiling never rides the public listener. The profiles that matter: profile (CPU, sampled at 100Hz for a duration you request: /debug/pprof/profile?seconds=30), heap (live allocations, with inuse_space/inuse_objects for "what is holding memory now" versus alloc_space/alloc_objects for "what allocates most over time", picking the wrong one is a classic misdiagnosis), goroutine (every goroutine's stack, the leak detector), block (time blocked on channels and locks, off by default until runtime.SetBlockProfileRate), mutex (contended lock holders, enabled via runtime.SetMutexProfileFraction), and allocs plus threadcreate. Capture is one command: go tool pprof http://host:6060/debug/pprof/heap fetches and drops you into the interactive analyser; top shows the heaviest functions with flat (time/bytes in the function itself) versus cum (including callees), a distinction interviewers explicitly test; list FuncName annotates source lines; web renders the call graph; and -http=:8081 serves the modern UI whose flame graph view is where most real reading happens, wide frames are where the resources go.
Safe production practice: CPU profiling costs a few percent while active, heap profiles are cheap snapshots (sampling, default one sample per 512KB allocated, controlled by runtime.MemProfileRate); continuous profilers (Grafana Pyroscope, Parca, Datadog) productionise this by collecting low-overhead profiles fleet-wide so you can diff "this deploy versus last" after an incident instead of reproducing under pressure. The habits that signal seniority: always profile before optimising; compare two heap profiles with pprof -base old.pb.gz new.pb.gz to isolate a leak's delta; grab a goroutine dump the moment a service wedges (it survives even when everything else is stuck); and know the common verdicts, JSON marshalling and allocation-heavy string building dominating CPU, sub-slice pinning dominating heap, one mutex serialising a fleet of goroutines in the mutex profile.
package main
import (
"log"
"net/http"
_ "net/http/pprof" // registers /debug/pprof/* on DefaultServeMux
"runtime"
)
func main() {
runtime.SetMutexProfileFraction(5) // sample 1/5 of contention events
runtime.SetBlockProfileRate(1000) // ns granularity for block profile
// internal-only listener: never expose pprof publicly
go func() {
log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
}()
// ... start the real server on :8080 ...
select {}
}
// Capture and analyse:
// go tool pprof -http=:8081 http://127.0.0.1:6060/debug/pprof/profile?seconds=30
// go tool pprof http://127.0.0.1:6060/debug/pprof/heap
// (pprof) top -> flat vs cum
// (pprof) list Handler -> line-level cost
// curl .../debug/pprof/goroutine?debug=1 -> leak triage
// go tool pprof -base heap_old.pb.gz heap_new.pb.gz -> leak delta
Key Points
- Serve pprof on an internal port; blank-import net/http/pprof
- inuse_* answers "what holds memory"; alloc_* answers "what churns"
- flat = in the function; cum = including callees
- Diff profiles with -base; goroutine dump first when a service wedges
Q45time.After, time.NewTimer, and time.Ticker: what leaked before Go 1.23, what changed, and what patterns are correct now?
IntermediateConcurrency
Answer
time.After(d) returns a channel that fires once after d. The historical problem: the underlying timer could not be stopped, so the famous anti-pattern, select with case <-time.After(5*time.Minute) inside a loop processing frequent messages, created a new five-minute timer per iteration, each pinned in the runtime's timer heap until expiry; at high message rates that is thousands of live timers and megabytes of held memory, a leak pattern documented in countless postmortems. The prescribed fix was time.NewTimer with explicit lifecycle: t.Stop() when done, and the pre-1.23 dance of draining the channel (if !t.Stop() { <-t.C }) before Reset, itself racy enough that the docs warned against misuse.
Go 1.23 changed the runtime model: timer channels became unbuffered and timers unreferenced by the runtime once nothing refers to them, meaning un-fired time.After timers are now garbage collected when unreachable, and Timer.Reset/Stop no longer have the stale-value race, the drain-before-reset dance is obsolete for code built with go.mod at 1.23+. The practical guidance that follows: time.After in a loop is no longer a memory leak in modern modules, but it still allocates a timer per iteration, so a reused NewTimer with Reset remains the efficient loop idiom; and in servers, per-operation deadlines should be context.WithTimeout anyway, which composes with cancellation. time.Ticker fires repeatedly at an interval: always defer tk.Stop() (though 1.23's collection also softens forgotten Stops), remember it drops ticks if the receiver is slow rather than queueing them (by design, the channel has a one-slot buffer), and never use a ticker for "run every X" work that can overrun its interval without deciding the overlap policy explicitly. time.Tick, once shunned as unstoppable, is acceptable in modern code for program-lifetime tickers. Interviewers still show the pre-1.23 loop and expect you to identify both the historical leak and the version boundary that changed the answer, version-aware nuance is precisely what this question screens for.
package main
import (
"fmt"
"time"
)
func consume(msgs <-chan string) {
// Efficient idle-timeout loop: one timer, Reset per message.
idle := time.NewTimer(30 * time.Second)
defer idle.Stop()
for {
select {
case m, ok := <-msgs:
if !ok {
return
}
fmt.Println("got", m)
// Go 1.23+: plain Reset, no drain dance needed
idle.Reset(30 * time.Second)
case <-idle.C:
fmt.Println("idle timeout, shutting down consumer")
return
}
}
}
func main() {
msgs := make(chan string, 3)
msgs <- "a"
msgs <- "b"
close(msgs)
consume(msgs)
}
Key Points
- Pre-1.23: time.After in loops pinned timers until expiry (leak)
- 1.23: unreferenced timers are GC-able; Reset/Stop races removed
- Reused NewTimer+Reset still avoids per-iteration allocation
- Tickers drop ticks when the receiver is slow, by design
Q46How does log/slog structured logging work, and how do you design logging for a Go service that ships to a log aggregator?
IntermediateObservability
Answer
log/slog (Go 1.21) made structured logging standard-library: a Logger front-end emits Records to a Handler back-end, with slog.NewJSONHandler and slog.NewTextHandler built in. Calls attach typed key-value attrs: slog.Info("payment settled", "order_id", id, "amount_paise", amt), or the allocation-conscious form slog.Info(msg, slog.String("order_id", id), slog.Int64("amount_paise", amt)). Levels are Debug/Info/Warn/Error with numeric gaps for custom levels; the handler filters by level, dynamically adjustable via slog.LevelVar so you can flip a live service to debug without redeploying, an underused operational trick worth naming.
The API pieces that matter in design discussions: logger.With("request_id", rid) returns a child logger with pre-bound attrs, the idiomatic way to thread request context so every downstream line carries the correlation ID; WithGroup namespaces attrs; the context-aware variants (InfoContext(ctx, ...)) pass ctx to the handler, which is how you lift OpenTelemetry trace_id and span_id into every log line by writing a small wrapping handler, the trick that makes logs clickable from traces in Grafana or SigNoz; and custom handlers wrap others to redact PII keys, sample noisy lines, or fan out. slog.SetDefault also routes the old log package through your handler, capturing dependencies' output. Design guidance interviewers listen for: log JSON in production because aggregators (Loki, OpenSearch, CloudWatch) index fields, not regexes; standardise key names across services (a tiny internal package of attr constructors beats a style doc); log errors once at the top with the wrapped chain rather than at every level; never log secrets or full card numbers (redaction handler, and a LogValuer implementation on sensitive types so they self-redact, slog calls LogValue() automatically); and treat log volume as a cost line, per-line attrs are cheap but a debug-level flood through a JSON handler is measurable CPU. On performance, slog's Attr forms avoid interface boxing where possible; zap and zerolog still benchmark faster and remain common, but the default answer for new services in 2026 is slog unless profiling says otherwise.
package main
import (
"log/slog"
"os"
)
type Card struct{ Number string }
// self-redacting sensitive type
func (c Card) LogValue() slog.Value {
return slog.StringValue("****" + c.Number[len(c.Number)-4:])
}
func main() {
var level slog.LevelVar // flip at runtime: level.Set(slog.LevelDebug)
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: &level,
}))
slog.SetDefault(logger)
reqLog := logger.With("request_id", "req-7f3a", "service", "payments")
reqLog.Info("payment settled",
slog.Int64("amount_paise", 129900),
slog.Any("card", Card{Number: "4111111111111111"}),
)
// {"level":"INFO","msg":"payment settled","request_id":"req-7f3a",
// "service":"payments","amount_paise":129900,"card":"****1111"}
}
Key Points
- Logger/Handler split; JSONHandler for aggregators, With for request context
- slog.LevelVar enables live log-level changes without redeploy
- LogValuer lets sensitive types redact themselves
- Context variants + wrapping handler inject trace_id into every line
Q47Why is Go the default language for gRPC services, and what should you know about protoc-gen-go, streaming modes, and error handling on the wire?
IntermediateEcosystem
Answer
gRPC and Go grew up together (both from Google), and google.golang.org/grpc is a first-class implementation, which is why service meshes and infra APIs (etcd, containerd, the Kubernetes CRI) are gRPC-over-Go. The workflow: define messages and services in .proto files, generate code with protoc (or the newer buf toolchain, which most teams now prefer for linting and breaking-change detection) using the protoc-gen-go and protoc-gen-go-grpc plugins; generation produces typed structs, a client interface, and a server interface you implement. Registration pairs your implementation with a grpc.Server, and clients dial with grpc.NewClient.
Four call shapes exist and interviews check you can map use cases onto them: unary (request/response, most APIs), server streaming (one request, many responses: tailing logs, market data feeds), client streaming (many requests, one response: uploads, metric batches), and bidirectional streaming (chat, sync protocols); streams are backed by HTTP/2 and each stream call gets a Send/Recv API where Recv returns io.EOF at clean end-of-stream, an error you must treat as success. Error handling is its own discipline: Go error chains do not cross the wire; you return status errors built with status.Errorf(codes.NotFound, "order %s", id), the client recovers the code with status.FromError, and mapping domain errors to the canonical codes (InvalidArgument, NotFound, AlreadyExists, ResourceExhausted, Unavailable) at the boundary is the gRPC analogue of HTTP status mapping; richer typed details ride status.WithDetails. Production concerns that elevate the answer: deadlines propagate automatically from client ctx to server (ctx.Err() inside the handler observes them), unlike hand-rolled HTTP clients; interceptors (unary and stream, client and server) are the middleware story, where auth, logging, retries, and OpenTelemetry instrumentation live; keepalive parameters need tuning through L4 load balancers that silently drop idle HTTP/2 connections; and client-side load balancing matters because a single HTTP/2 connection to a Kubernetes ClusterIP pins all traffic to one pod, the standard fixes being headless services with round_robin or a mesh. In India, this is the shape most payments and consumer-scale platforms converge on for internal service-to-service traffic, and it is what interviewers at Razorpay, Gojek, and Grab probe when a role mentions microservices.
// order.proto
// syntax = "proto3";
// service Orders {
// rpc Get(GetReq) returns (Order);
// rpc Watch(WatchReq) returns (stream Order); // server streaming
// }
// server implementation (generated interface)
func (s *server) Get(ctx context.Context, req *pb.GetReq) (*pb.Order, error) {
o, err := s.store.Find(ctx, req.GetId())
if errors.Is(err, ErrNotFound) {
return nil, status.Errorf(codes.NotFound, "order %s", req.GetId())
}
if err != nil {
return nil, status.Error(codes.Internal, "lookup failed")
}
return o, nil
}
// client: deadline propagates to the server automatically
ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
defer cancel()
order, err := client.Get(ctx, &pb.GetReq{Id: "ord_42"})
if status.Code(err) == codes.NotFound {
// handle 404-equivalent
}
Key Points
- buf + protoc-gen-go/protoc-gen-go-grpc generate typed client/server
- Four shapes: unary, server-stream, client-stream, bidi; Recv EOF = done
- status codes cross the wire; Go error chains do not
- Deadlines propagate via ctx; interceptors are the middleware layer
Q48How do you build a minimal production Docker image for a Go service: CGO_ENABLED=0, multi-stage builds, cross-compilation, and ldflags version stamping?
IntermediateDeployment
Answer
Go compiles to a single static binary, which makes its container story the best in the industry if you use it properly. The levers: CGO_ENABLED=0 forces pure-Go implementations (notably the net resolver and os/user), removing all libc dependence so the binary runs in an empty filesystem; GOOS/GOARCH cross-compile from any machine (GOOS=linux GOARCH=arm64 go build on a Mac produces a Linux ARM binary with no toolchain gymnastics, this is why Go CI images deploy to Graviton so easily); and -ldflags does two jobs, "-s -w" strips symbol tables and DWARF debug info (smaller binary; note it hampers debugger use, not panic stack traces), while -X importpath.Var=value injects the git SHA and build time into package variables at link time, so /version endpoints report exactly what is deployed, the standard mechanism behind every "what build is prod running" answer. The Dockerfile pattern is multi-stage: a golang:1.25 builder stage runs the build (with COPY go.mod go.sum + go mod download as its own layer before COPY . so dependency layers cache across code changes, the difference between 8-minute and 20-second CI builds), and the runtime stage starts FROM scratch or, more practically, FROM gcr.io/distroless/static, which adds CA certificates, a nonroot user, and tzdata: the three things scratch images always end up missing, discovered as x509 certificate errors on the first outbound HTTPS call.
Final images land around 10-25MB versus a gigabyte-class node or JVM image, with a near-empty CVE scan since there is no distro userland. Round it out with the operational details reviewers probe: run as USER nonroot, add a proper healthcheck endpoint rather than a shell (scratch has no shell, which is a feature), consider GOFLAGS=-trimpath so build paths are not embedded, and remember go build honours the toolchain directive in go.mod so the builder image version and go.mod stay consistent.
# Dockerfile
FROM golang:1.25 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download # cached layer: reruns only on dep change
COPY . .
ARG GIT_SHA=dev
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath \
-ldflags="-s -w -X main.version=${GIT_SHA}" \
-o /out/api ./cmd/api
FROM gcr.io/distroless/static:nonroot # CA certs + tzdata + nonroot
COPY --from=build /out/api /api
USER nonroot
ENTRYPOINT ["/api"]
# main.go
# var version = "dev" // overwritten by -X at link time
Key Points
- CGO_ENABLED=0 = fully static binary, runs FROM scratch/distroless
- Cache go mod download as its own Docker layer
- -ldflags -X stamps git SHA; -s -w strips debug info
- distroless/static adds CA certs, tzdata, nonroot that scratch lacks
Q49Describe Go's garbage collector: the tricolor concurrent mark-sweep design, and how GOGC and GOMEMLIMIT tune it in production.
AdvancedRuntime
Answer
Go's collector is a concurrent, non-generational, non-compacting mark-sweep collector optimised for low pause times over raw throughput. The mark phase uses the tricolor abstraction: objects are white (unvisited, candidates for freeing), grey (reached, children pending), or black (fully scanned). Marking runs concurrently with the program, which creates the classic hazard: a running mutator could store a pointer to a white object into an already-black object, hiding it from the collector.
The write barrier prevents this, a small compiler-inserted hook on pointer writes during marking (Go uses a hybrid Dijkstra/Yuasa barrier) that shades the involved objects grey, preserving the invariant that black never points to unreachable white. Stop-the-world pauses still exist but only at the mark start and mark termination, typically well under a millisecond; the real cost of GC in Go services is not pauses but CPU stolen by concurrent marking (up to 25% of GOMAXPROCS as dedicated background marking, plus assist work charged to allocating goroutines when allocation outpaces marking, which is why allocation-heavy request handlers get slower under memory pressure, an effect you can see as mcall/gcAssistAlloc frames in CPU profiles). Tuning has exactly two supported knobs.
GOGC (default 100) sets the growth target: a new cycle starts when the heap grows 100% over the live heap from the previous cycle; raising it trades memory for less GC CPU, lowering it the reverse, and GOGC=off disables collection. GOMEMLIMIT (Go 1.19) sets a soft total-memory ceiling: the runtime collects more aggressively as usage approaches it, which finally solved the container OOM-kill pattern where a Kubernetes pod with a 512MiB limit died because GOGC alone let the heap overshoot; the standard production setup is GOMEMLIMIT at roughly 85-90% of the container limit with GOGC left at default or raised. Two closing points that mark real depth: because the collector never compacts, fragmentation is handled by size-class allocation (tcmalloc-style spans), and "tune GOGC" is the second answer, the first is always reducing allocation rate, found via the alloc_objects profile.
Key Points
- Tricolor concurrent mark-sweep; write barrier keeps the invariant
- Cost is concurrent CPU + mark assists, not long pauses
- GOGC = growth trigger; GOMEMLIMIT = soft ceiling for containers
- First optimisation is always allocating less, not knob-turning
Q50What does the Go memory model guarantee, what is a happens-before relationship, and why can a racy read see a 'torn' or stale value?
AdvancedMemory Model
Answer
The Go memory model (the document at go.dev/ref/mem, substantially revised in 2022) defines when one goroutine is guaranteed to observe another's writes: only when a happens-before relationship orders them. Within one goroutine, program order holds. Across goroutines, order is established solely by synchronisation: a channel send happens-before the corresponding receive completes; the close of a channel happens-before a receive that observes closure; the nth receive from a buffered channel with capacity C happens-before the (n+C)th send completes (the axiom that makes buffered channels usable as semaphores); the nth Unlock of a mutex happens-before the (n+1)th Lock returns; WaitGroup's Done calls happen-before Wait returns; once.Do's function happens-before any Do call returns; and sync/atomic operations behave like sequentially consistent synchronisation points (the 2022 revision formalised this).
Without such an edge, the model makes almost no promises: the compiler may reorder and cache, and the CPU may commit stores out of order, so a goroutine polling a plain bool done flag may never observe it becoming true (the read can be hoisted out of the loop), and a reader of a multi-word value being concurrently written, an interface, a slice header, a string header, can observe half-updated state: an interface whose type word points at one type and data word at another value is how racy Go programs produce segfaults from safe code. This is the precise technical grounding for "no benign races": the language promises nothing about racy executions except that implementations should try to keep misbehaviour local. The practical doctrine to close with: do not reason about happens-before edges by hand in application code; use channels and sync primitives, run -race everywhere in CI, and reserve manual atomics for well-understood publication patterns (atomic.Pointer to an immutable struct being the canonical safe one). The interviewer follow-up is often "why is double-checked locking wrong with a plain flag but fine with sync.Once", and the answer is exactly this edge structure: Once's internal atomics create the edges the naive flag lacks.
package main
import (
"fmt"
"sync/atomic"
)
type Config struct{ Endpoint string }
var cfg atomic.Pointer[Config] // publication with proper ordering
func writer() {
c := &Config{Endpoint: "https://api.internal"} // build fully...
cfg.Store(c) // ...then publish
}
func reader() {
if c := cfg.Load(); c != nil {
// Guaranteed to see a fully-constructed Config:
// the atomic Store/Load pair creates the happens-before edge.
fmt.Println(c.Endpoint)
}
}
// BROKEN version for contrast:
// var ready bool ; var conf *Config
// writer: conf = &Config{...}; ready = true // plain writes
// reader: for !ready {} ; use conf // may spin forever,
// or observe ready==true with conf still nil: no ordering exists.
func main() { writer(); reader() }
Key Points
- Visibility across goroutines requires a happens-before edge
- Edges: chan send/recv & close, mutex unlock/lock, WaitGroup, Once, atomics
- Racy multi-word reads can tear interfaces and slice headers (crashes)
- Publish immutable state via atomic.Pointer; never plain-flag polling
Q51What is inside a channel at runtime (hchan), and how do you decide between channels and mutexes for a given design?
AdvancedInternals
Answer
A channel is a runtime-allocated hchan struct: a circular buffer (for buffered channels) with sendx/recvx indices, the element type and size, a closed flag, two wait queues (sendq and recvq) of parked goroutines represented as sudog nodes, and one mutex guarding it all. Every channel operation takes that lock, which yields the first design consequence: a single channel serialises its operations, and a hot channel shared by many goroutines becomes a contention point visible in mutex/block profiles. The operations follow from the struct.
Send: if a receiver waits in recvq, copy the value directly into the receiver's stack slot and wake it (bypassing the buffer entirely, an optimisation worth naming); else if buffer space exists, enqueue; else park the sender as a sudog on sendq. Receive mirrors it. Close: sets the flag and wakes every parked waiter, receivers get zero values, parked senders panic.
Parking uses gopark, handing the M to the scheduler, so blocked channel operations cost no OS thread, the property that makes channel-heavy designs scale. Now the design question interviewers actually care about. Use a mutex when you are protecting state: shared maps, counters, caches, anything where the operation is "briefly touch this structure"; a mutex is smaller, faster for short critical sections, and does not force an ownership handoff.
Use channels when you are transferring data or ownership between goroutines, building pipelines, distributing work, signalling lifecycle (done/quit/ctx.Done), or when "exactly one goroutine owns this data at a time" is the invariant, encoding ownership transfer in the type system of your architecture. The Go proverb "share memory by communicating" is directional advice, not a ban on mutexes: the standard library itself is full of mutexes. Two failure modes to volunteer: using a channel as a mutex (capacity-1 semaphore) works but obscures intent and benchmarks slower than sync.Mutex; and fine-grained state guarded by a single channel-owning goroutine (the "actor" pattern) adds queueing latency per access, which is a fine trade for complex invariants and a poor one for a hot counter, atomic.Int64 exists for that.
Key Points
- hchan: ring buffer + sendq/recvq of parked sudogs + one lock
- Direct handoff: sender can copy straight into a waiting receiver's stack
- Mutexes protect state; channels transfer data/ownership and signal
- Hot shared channels contend on their internal lock: profile it
Q52How does sync.Pool actually work with the GC, when does it help, and what are its documented pitfalls?
AdvancedPerformance
Answer
sync.Pool is a set of temporary objects that may be reused to relieve allocator and GC pressure: Get returns a pooled object or calls New, Put returns one for reuse. Internally it is sharded per-P (one local pool per scheduler processor, with a private slot and a shared queue that other Ps can steal from), so uncontended Get/Put touch no global lock, which is why it scales where a mutex-guarded free list would not. Its defining behaviour is GC integration: pool contents are cleared across garbage collections (the implementation keeps one "victim" generation, so an object survives roughly one GC cycle before being freed), meaning a pool is a cache for high-frequency reuse between collections, not long-term storage; when allocation pressure is low the pool empties itself and costs nothing.
It shines for expensive-to-allocate, high-churn, uniform objects on hot paths: the canonical users are fmt (its internal pp printer structs), encoding/json buffers, bytes.Buffer/[]byte scratch space in HTTP handlers, and gzip writers (compression state is genuinely expensive to rebuild). The pitfalls are specific and quotable. First, unbounded memory retention through size variance: a pool of byte slices where one request inflates a buffer to 64MB keeps that giant buffer cycling; the fix, used inside the standard library, is discarding oversized buffers instead of Putting them back.
Second, you must reset objects on Put or Get (buf.Reset()); leaking one request's data into another's buffer is a security bug, not just a correctness one. Third, staticcheck SA6002 flags Putting non-pointer values, which allocates on the way into the pool, defeating the purpose; pool pointers (*bytes.Buffer, *[]byte). Fourth, never pool objects with external state (connections, file handles): the pool can drop them silently at GC, and Put-ing something still referenced elsewhere is instant aliasing corruption. Finally, the honest engineering point: sync.Pool is an optimisation of last resort after the allocation profile proves the churn matters; used speculatively it complicates ownership for unmeasured benefit, and interviewers reward saying so.
package main
import (
"bytes"
"fmt"
"sync"
)
var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
const maxPooled = 64 << 10 // don't retain monsters
func render(name string) string {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset() // NEVER trust pooled state
defer func() {
if buf.Cap() <= maxPooled {
bufPool.Put(buf) // pointer type: no SA6002 boxing
}
}()
fmt.Fprintf(buf, "invoice for %s", name)
return buf.String()
}
func main() {
fmt.Println(render("Asha Traders"))
}
Key Points
- Per-P sharding: uncontended Get/Put, no global lock
- Cleared across GCs (victim cache): reuse window is one cycle
- Reset on reuse; drop oversized buffers; pool pointers (SA6002)
- Last-resort optimisation: justify it with an allocation profile
Q53The default http.Client has no timeout. Walk through configuring a production-grade HTTP client in Go and the failure modes each setting prevents.
AdvancedProduction
Answer
http.DefaultClient and a zero-value http.Client have Timeout: 0, no timeout at all, so a hung upstream (accepting connections, never responding) parks your goroutines forever; under load, every worker eventually blocks on the dead dependency, and your service dies of a neighbour's outage. That cascade is the single most common Go production incident pattern, and the interview question is really "show me you have configured every layer". The layers: Client.Timeout caps the entire exchange (dial + TLS + request + reading the body); it is the blunt outer bound.
Finer control lives in the Transport: DialContext timeout (TCP connect), TLSHandshakeTimeout, ResponseHeaderTimeout (time to first response byte after the request is written, the setting that catches "accepted but hung" servers), ExpectContinueTimeout, and IdleConnTimeout for the keep-alive pool. Per-request deadlines that compose with cancellation come from ctx via http.NewRequestWithContext, which is the right mechanism when different calls need different budgets against one shared client. Connection pooling is the second failure cluster: Transport keeps idle connections keyed by host with MaxIdleConns (default 100) but MaxIdleConnsPerHost defaults to just 2, so a service hammering one upstream at high concurrency opens and closes connections constantly, burning ephemeral ports (visible as TIME_WAIT pileups and connect latency); production configs raise MaxIdleConnsPerHost to match concurrency toward that host and set MaxConnsPerHost as a ceiling protecting the upstream.
Reuse depends on you: read bodies to EOF and Close them, else the connection cannot return to the pool. Structural rules: create one http.Client per upstream and share it (it is concurrency-safe; per-request clients destroy pooling and re-handshake TLS), never mutate DefaultTransport, and wrap retries with exponential backoff plus jitter only for idempotent requests, honouring Retry-After, ideally alongside a circuit breaker (gobreaker or similar) so a dying upstream is shed rather than hammered. Close by naming observability: httptrace.ClientTrace exposes DNS/connect/TTFB timings, which is how you prove which layer a latency regression lives in.
package main
import (
"context"
"net"
"net/http"
"time"
)
func newUpstreamClient() *http.Client {
return &http.Client{
Timeout: 10 * time.Second, // outer bound: whole exchange
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 2 * time.Second, // TCP connect
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 2 * time.Second,
ResponseHeaderTimeout: 5 * time.Second, // catches hung servers
MaxIdleConns: 200,
MaxIdleConnsPerHost: 100, // default 2 starves hot paths!
MaxConnsPerHost: 200,
IdleConnTimeout: 90 * time.Second,
},
}
}
func call(ctx context.Context, c *http.Client) error {
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
"https://settlements.internal/v1/status", nil)
resp, err := c.Do(req)
if err != nil {
return err
}
defer resp.Body.Close() // required for connection reuse
return nil
}
Key Points
- Zero-value client = infinite timeout = cascading outage under load
- ResponseHeaderTimeout catches accepted-but-hung upstreams
- MaxIdleConnsPerHost default is 2: raise it or churn connections
- One shared client per upstream; ctx deadlines per request
Q54How do Go 1.23 range-over-func iterators work (iter.Seq, iter.Seq2), and how do you write and consume one?
AdvancedVersion Changes
Answer
Go 1.23 made user-defined iterators a language feature: a for-range statement can range over a function with one of three shapes, and the standard iter package names the two useful ones: iter.Seq[V] is func(yield func(V) bool) and iter.Seq2[K, V] is func(yield func(K, V) bool). The mechanics invert control: your iterator function runs the loop internally and calls yield once per element; the compiler rewrites the consumer's range body into that yield callback. yield returning false means the consumer broke out of the loop (break, return, or an early exit), and your iterator must stop immediately, forgetting to check yield's result is the canonical bug when hand-writing one, and continuing to call yield after it returned false panics with a runtime error. The payoff is composable, allocation-light streaming without materialising slices: iterators over database rows, tree traversals, or token streams let consumers use ordinary range syntax, breaks and all, while producers keep push-style control flow (defer-based cleanup inside the iterator works naturally, running when iteration ends for any reason).
The standard library adopted them across Go 1.23/1.24: maps.Keys and maps.Values return iter.Seq (no more allocated key slices; wrap with slices.Collect or slices.Sorted when you need a slice), slices.All/Values/Backward, strings.SplitSeq and strings.Lines avoid allocating the full split result, and reflect and regexp grew Seq-returning APIs. The iter package also provides Pull, converting a push iterator into a next()/stop() pair for the cases range cannot express, merging two sorted sequences being the classic; always call stop to release the underlying (coroutine-based) resources. Points that distinguish a strong answer: the three permitted function shapes (no yield parameter, one, or two); that this landed alongside 1.22's range-over-int as a staged expansion of range; performance intuition, the yield calls typically inline so simple iterators compete with hand-written loops, but Pull has real overhead; and a taste judgement interviewers respect: iterators are for streams and lazy sequences, not a replacement for returning a small slice.
package main
import (
"fmt"
"iter"
"maps"
"slices"
)
// custom iterator: emits Fibonacci numbers up to limit
func fib(limit int) iter.Seq[int] {
return func(yield func(int) bool) {
a, b := 0, 1
for a <= limit {
if !yield(a) { // consumer broke out: stop NOW
return
}
a, b = b, a+b
}
}
}
func main() {
for v := range fib(100) {
if v > 50 {
break // yield returns false inside fib
}
fmt.Println(v)
}
ages := map[string]int{"asha": 31, "ravi": 27}
names := slices.Sorted(maps.Keys(ages)) // Seq -> sorted slice
fmt.Println(names) // [asha ravi]
next, stop := iter.Pull(fib(1000)) // push -> pull conversion
defer stop()
v, _ := next()
fmt.Println(v) // 0
}
Key Points
- iter.Seq = func(yield func(V) bool); range body becomes the yield callback
- yield false = consumer broke out; iterator must return immediately
- maps.Keys/Values, strings.SplitSeq now stream instead of allocating
- iter.Pull for merge-style consumption; always call stop
Q55Your Go service runs in Kubernetes with a CPU limit. What goes wrong with GOMAXPROCS defaults, and what changed in Go 1.25?
AdvancedRuntime
Answer
Historically GOMAXPROCS defaulted to the machine's logical CPU count, and a container sees the node's CPUs, not its own limit. A pod with a 2-CPU cgroup quota on a 64-core node therefore ran with GOMAXPROCS=64: sixty-four Ps worth of runnable goroutines, GC background workers sized to 64 cores, all squeezed through a 2-CPU quota enforced by CFS bandwidth control in 100ms accounting periods. The symptom is throttling: the process burns its quota early in each period and is frozen for the remainder, which manifests as bizarre tail latency (p99 spikes of tens to hundreds of milliseconds with low average utilisation), GC assists piling up, and dashboards showing container_cpu_cfs_throttled_periods_total climbing while the service looks idle.
Because the freeze hits whatever goroutine holds a lock or is mid-GC, the latency is unpredictable and reproduces poorly, classic "only in prod" behaviour. The long-standing fix was uber-go/automaxprocs (reads the cgroup quota at startup and sets GOMAXPROCS to match) or setting the env var explicitly in the pod spec, often via the Downward API from resources.limits.cpu. Go 1.25 made the runtime container-aware: on Linux it now considers the cgroup CPU quota (v1 and v2) when computing the default GOMAXPROCS, effectively rounding the quota up to an integer, and it updates if the limit changes, removing the automaxprocs dependency for the common case.
The senior-level nuances interviewers dig for: GOMAXPROCS bounds only running Go code, so a low setting with many blocking syscalls still spawns extra Ms; fractional limits (500m) round up, and you should still avoid sub-1-CPU limits for latency-sensitive Go services because GC and the scheduler need real parallelism; requests-without-limits (burstable QoS) changes the calculus, many platform teams drop CPU limits entirely and let requests drive scheduling, sidestepping throttling; and memory has the parallel story, GOMEMLIMIT from the pod memory limit, typically via the Downward API, to avoid OOM kills. Being able to connect the runtime flag to the CFS mechanism to the p99 graph is exactly the observability-first debugging this question screens for.
Key Points
- Old default: node CPUs, not cgroup quota; result = CFS throttling
- Symptom: p99 spikes + throttled_periods_total with low avg CPU
- Go 1.25: runtime respects cgroup quota (replaces automaxprocs)
- Pair with GOMEMLIMIT from pod limits; avoid sub-1-CPU limits
Q56What is profile-guided optimization (PGO) in Go, how do you adopt it, and what wins does it actually deliver?
AdvancedPerformance
Answer
PGO feeds a production CPU profile back into the compiler so it optimises the code paths that actually run hot, rather than treating all code equally. The workflow was deliberately made boring: collect a pprof CPU profile from production (a representative window, e.g. 30 seconds during normal peak; merge several with go tool pprof -proto if traffic varies), drop it at the main package root named default.pgo, and rebuild; go build finds it automatically (or takes -pgo=path/to/profile), and go version -m on the binary shows the applied profile. What the compiler does with it: more aggressive inlining of hot functions (the standard inlining budget is conservative; PGO raises it exactly where the profile says it pays), devirtualisation of hot interface method calls (when the profile shows one concrete type dominating a call site, the compiler emits a type check plus a direct, inlinable call with the indirect call as fallback), and better basic-block and register-allocation decisions on hot paths.
Realistic expectations, which is what the question probes: the Go team cites low-to-mid single digit CPU improvements for typical services (commonly quoted as roughly 2-7%), with larger wins on interface-heavy hot paths; it is not a rewrite-level speedup, but it is nearly free, so at fleet scale it is one of the highest ROI switches available, and companies running thousands of Go pods treat it as standard. Operational design points that make the answer senior: profiles are forward-compatible and stale profiles degrade gracefully (the compiler just optimises yesterday's hot paths, still usually right), so the standard pipeline is a weekly job pulling merged profiles from your continuous profiler (Pyroscope, Parca, or plain pprof scrapes) and committing default.pgo, keeping builds reproducible since the profile is a checked-in artifact; build times rise modestly; and you verify wins with benchstat against production metrics rather than trusting microbenchmarks. Mention the interaction with the broader toolchain: PGO landed in 1.20 (preview) and 1.21 (GA), and subsequent releases keep widening what it devirtualises and inlines, so the payoff drifts upward with toolchain upgrades you were doing anyway.
# 1. capture a representative production CPU profile
curl -o cpu1.pb.gz 'http://svc:6060/debug/pprof/profile?seconds=30'
curl -o cpu2.pb.gz 'http://svc:6060/debug/pprof/profile?seconds=30'
# 2. merge windows into one profile
go tool pprof -proto cpu1.pb.gz cpu2.pb.gz > merged.pb.gz
# 3. commit it where `go build` auto-discovers it
cp merged.pb.gz ./cmd/api/default.pgo
# 4. rebuild: PGO applies automatically
go build ./cmd/api
go version -m ./api | grep pgo # confirm profile was used
# CI alternative: explicit flag
go build -pgo=profiles/weekly.pb.gz ./cmd/api
Key Points
- default.pgo at the main package root; auto-applied by go build
- Wins: hot-path inlining + interface devirtualisation, roughly 2-7% CPU
- Stale profiles degrade gracefully; refresh via a weekly pipeline
- Verify with production metrics/benchstat, not microbenchmarks
Q57A Go pod keeps getting OOM-killed but heap profiles look small. Walk through the full diagnosis: where Go memory hides beyond the heap.
AdvancedProduction
Answer
Exit code 137 with a modest heap profile is a standard senior interview scenario because the answer requires knowing everything the heap profile does not show. Structure the diagnosis. First, establish what the kernel counted: the cgroup memory limit covers the whole process RSS, and Go's own accounting is visible in runtime/metrics or an expvar/Prometheus exporter; compare HeapInuse against the pod limit and note the gap.
Then enumerate where the gap lives. Goroutine stacks: a goroutine leak at, say, 200k goroutines is gigabytes of stack memory that never appears in heap profiles, check /debug/pprof/goroutine first, always. GC headroom: with default GOGC=100 the heap legitimately grows to twice the live set between cycles, so a 300MB live heap can hold 600MB+ before collection; GOMEMLIMIT (set to ~85-90% of the pod limit) is the designed fix, forcing collection before the cgroup kills you.
Released-but-retained pages: the runtime returns memory to the OS gradually, and HeapReleased versus HeapIdle tells you whether memory is truly given back; kernels also count lazily-reclaimed pages differently, which misleads naive RSS reading. Off-heap allocations invisible to pprof entirely: cgo mallocs (anything wrapping a C library: librdkafka, sqlite, image codecs), mmap'd regions from embedded stores like BadgerDB, and OS buffers for thousands of sockets. Profile-view traps: the heap profile samples allocations (default one per 512KB), shows inuse_space for live objects only, and excludes stacks and runtime structures; and a common real culprit, sub-slice pinning, shows up as small inuse sizes with surprising retained graphs, use pprof's peek/traces and compare -base snapshots over time to find growth.
Finally the fix menu by cause: leaks get context-scoped lifecycles and goleak tests; GC headroom gets GOMEMLIMIT (and possibly lower GOGC); cgo leaks get valgrind-style tooling on the C side or jemalloc profiling; buffer bloat gets pooling with size caps. Naming debug.FreeOSMemory as a diagnostic (not a fix) and knowing GODEBUG=madvdontneed=1 existed for older-kernel RSS reporting rounds out an already-strong answer.
Key Points
- Heap profile omits: goroutine stacks, GC headroom, cgo, mmap, sockets
- Goroutine dump first; leaks are stack memory pprof heap never shows
- GOGC=100 means ~2x live heap; GOMEMLIMIT keeps you under the cgroup
- Diff heap snapshots with -base; sub-slice pinning hides retained bytes
Q58How does testing/synctest (Go 1.25) let you test time-dependent concurrent code deterministically, and what problem does it solve?
AdvancedTesting
Answer
The problem: testing code that involves timers, timeouts, retries with backoff, or debouncing has always forced a bad choice. Either use real time, making tests slow (a test of a 30-second timeout takes 30 seconds) and flaky under CI load (a 10ms sleep "deadline" misses when the runner stalls), or inject a fake clock interface everywhere, polluting production code with abstraction purely for testability, the clockwork/benbjohnson-clock pattern. Sleeps-as-synchronisation is the flakiness king: time.Sleep(50*time.Millisecond) then asserting a goroutine finished is a race with the scheduler that fails once a week fleet-wide. testing/synctest, experimental in 1.24 (GOEXPERIMENT=synctest) and stable in Go 1.25, solves this at the runtime level. synctest.Test(t, func(t *testing.T){...}) runs the function in a "bubble": goroutines started inside it use a fake clock, and time only advances when every goroutine in the bubble is durably blocked (sleeping, waiting on a channel or WaitGroup owned by the bubble), at which point the clock jumps instantly to the next timer.
The consequence is remarkable: a test exercising a 30-second timeout completes in microseconds, deterministically, with production code unchanged, real time.Sleep, real time.After, real context.WithTimeout all behave correctly against the virtual clock. The companion synctest.Wait() blocks until all other goroutines in the bubble are durably blocked, giving you a precise "everything has settled, now assert" point, exactly what the sleeps were approximating. Constraints worth naming to show real understanding: everything under test must live inside the bubble, goroutines communicating with outside goroutines or doing real I/O (network syscalls) are not durably blocked, so this targets in-process concurrency logic, not integration tests; bubbled channels must be created inside the bubble; and the classic use cases are exactly the previously untestable ones, cache expiry, rate limiters, heartbeat/timeout logic, debounced writers, retry backoff sequencing. Interview relevance is rising fast because it changes an old best practice: "inject a clock interface" is no longer automatically the answer, and knowing the version boundary (experiment in 1.24, stable in 1.25) plus the durably-blocked rule signals you track the language's evolution.
package cache
import (
"testing"
"testing/synctest"
"time"
)
// production code, no clock injection:
type Entry struct{ expires time.Time }
func (e Entry) Valid() bool { return time.Now().Before(e.expires) }
func TestExpiry(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
e := Entry{expires: time.Now().Add(30 * time.Second)}
if !e.Valid() {
t.Fatal("should be valid when fresh")
}
time.Sleep(31 * time.Second) // virtual: completes instantly
if e.Valid() {
t.Fatal("should have expired")
}
})
// Whole test runs in microseconds, deterministically.
}
Key Points
- Bubble with fake clock; time advances only when all goroutines block
- 30-second timeout tests run in microseconds, zero flakiness
- synctest.Wait() = precise settle point, replacing sleep-and-hope
- In-process logic only; real I/O is not durably blocked
Q59Swiss-table maps, the unique package, and weak pointers: what did Go 1.24 change about memory and data structures?
AdvancedVersion Changes
Answer
Go 1.24 shipped three related memory-level changes that interviewers increasingly use to separate candidates who track the runtime from those who stopped at 1.18. First, the built-in map was reimplemented on Swiss tables (the open-addressing, SIMD-probing design from Abseil): instead of the old bucket-chain layout, groups of slots share a compact metadata byte array that the runtime probes several entries at a time, improving cache behaviour. The practical result is faster access and iteration on large maps (the release notes cite meaningful speedups on big maps and modest gains elsewhere) and lower memory overhead, with zero API change, your code just gets faster on rebuild, which is the compatibility-promise story worth telling.
It also further cements that iteration order and internal layout are unspecified: code that survived by accident on layout assumptions is exactly what such rewrites break. Second, the unique package: unique.Make[T](v) returns a canonical Handle[T] for a comparable value, interning it in a global, concurrently-safe, GC-integrated table, duplicate values share one canonical copy, handles compare with a single pointer comparison, and, crucially, entries are reclaimed when no handles remain (unlike hand-rolled intern maps, which leak forever or need manual eviction). The use case is deduplicating high-cardinality repeated values, label strings in a metrics pipeline, symbol names, IPs in flow logs, where interning cuts both memory and comparison cost; net/netip uses it internally for address zones.
Third, the weak package gives weak.Make(ptr) producing a weak.Pointer[T] whose Value() returns nil once the referent is collected: the sanctioned building block for caches and canonicalisation structures that must not, by themselves, keep entries alive, previously impossible without unsafe tricks. It pairs with runtime.AddCleanup, also new in 1.24, a saner replacement for runtime.SetFinalizer (multiple cleanups per object, no resurrection semantics, fewer cycle-leak footguns). The thread joining all three: Go is steadily providing supported, GC-cooperating primitives for memory patterns people previously hacked around the runtime to get, and knowing when interning or weak references are appropriate (high-duplication data; observing caches) versus overkill is the judgement layer interviewers actually probe.
Key Points
- 1.24 maps: Swiss tables, faster large-map access, no API change
- unique.Make interns comparable values; GC reclaims unused entries
- weak.Pointer enables caches that do not pin their entries
- runtime.AddCleanup replaces SetFinalizer's footguns
Q60Summarise what changed across Go 1.21 to 1.25 that a candidate should actually know in a 2026 interview.
AdvancedVersion Changes
Answer
Six-month releases mean a 2026 interview spans five meaningful versions, and "what is new in Go" is now a standard closing question. The high-signal map: Go 1.21 (Aug 2023) added the min, max, and clear builtins, log/slog for structured logging, the slices/maps/cmp generic packages, sync.OnceFunc/OnceValue, PGO going GA, and forward toolchain management via the go.mod toolchain directive. Go 1.22 (Feb 2024) is the headline release: per-iteration loop variables killed the closure-capture bug class, range-over-int arrived, math/rand/v2 landed as the first v2 stdlib package (ChaCha8-based, no seeding ceremony), and net/http.ServeMux gained method patterns and path wildcards, repatriating routing from third-party muxes.
Go 1.23 (Aug 2024) delivered range-over-func iterators with the iter package (iter.Seq, iter.Seq2, Pull), the timer overhaul (unreferenced timers collectable, Reset/Stop races removed, making old time.After leak advice version-dependent), the unique package's precursor work, and structured telemetry opt-in. Go 1.24 (Feb 2025) brought full generic type aliases, the Swiss-table map rewrite, the tool directive in go.mod (ending the tools.go hack for pinning dev tools), testing.B.Loop for elision-proof benchmarks, os.Root for directory-confined filesystem access (a real path-traversal defence), weak pointers, runtime.AddCleanup, and native FIPS 140-3 crypto modes. Go 1.25 (Aug 2025) stabilised testing/synctest for deterministic concurrency tests, made GOMAXPROCS container/cgroup-aware, added WaitGroup.Go, shipped an experimental green-tea garbage collector (GOEXPERIMENT=greenteagc) aimed at better locality and reduced GC overhead, the experimental encoding/json/v2 (GOEXPERIMENT=jsonv2), and the flight recorder for always-on lightweight execution tracing. How to use this in interviews: anchor answers to behaviour, not release notes, "loop captures changed in 1.22, gated by the go.mod directive" or "time.After leaking is pre-1.23 advice" shows working knowledge; know that language semantics follow the go directive in go.mod, not the installed toolchain; and if asked what to check when upgrading a legacy service, the honest checklist is the go directive gating 1.22 loop semantics, dependency compatibility via go mod tidy, and rerunning -race and benchmarks since runtime changes (maps, timers, GC) shift performance profiles.
Key Points
- 1.21: slog, slices/maps, OnceValue, PGO GA. 1.22: loop vars, ServeMux, rand/v2
- 1.23: iterators + timer GC. 1.24: Swiss maps, tool directive, b.Loop, os.Root
- 1.25: synctest, container-aware GOMAXPROCS, WaitGroup.Go, greentea GC (exp)
- Semantics follow go.mod's go directive, not the installed toolchain
Frequently Asked Questions
What salary can a Go developer expect in India in 2026?
The realistic band is ₹10-30 LPA depending on experience and company tier. Freshers who land Go-specific roles (rare; most enter via general backend hiring) start around ₹6-12 LPA. With 2-4 years of production Go experience, product companies pay ₹15-25 LPA, and the top payers, Razorpay, CRED, Zerodha, Uber and Grab's India engineering offices, and infrastructure startups, go well past ₹30-50 LPA for senior engineers who can reason about the runtime, profiling, and distributed systems. Platform and SRE roles that combine Go with Kubernetes expertise command a premium over plain CRUD backend work, often 20-30% above equivalent Java or Node positions at the same level.
How long does it take to prepare for a Go interview?
If you already write a backend language professionally, four to six weeks of focused work is enough: one week on language semantics (slices, maps, interfaces, defer, error handling), two weeks on concurrency (goroutines, channels, select, context, sync primitives, the race detector) because that is where most interview time goes, and the remainder on the runtime and tooling: pprof, benchmarks, the GC, and recent version changes. Build at least one real project, a rate limiter, a job queue, or a small API with graceful shutdown and tests, because interviewers quickly detect candidates who have only read about channels. Complete beginners should budget three to four months to reach hireable depth.
What do interviewers expect from freshers versus experienced Go engineers?
Freshers are tested on language fundamentals and basic concurrency: slice/map behaviour, interface satisfaction, writing a correct worker pool with WaitGroup and channels, and table-driven tests. Nobody expects a fresher to tune GOGC. At 3+ years, expectations shift decisively to production judgement: diagnosing goroutine leaks from a pprof dump, configuring http.Client timeouts, explaining what -race guarantees, graceful shutdown under Kubernetes, and the trade-offs between channels and mutexes for a given design. Senior candidates get runtime questions (scheduler, GC, escape analysis) and system design where Go specifics matter: backpressure, connection pooling, and memory behaviour under container limits. Version awareness (loop variables in 1.22, iterators in 1.23) is increasingly used as a seniority signal at every level.
Is Go still worth learning in 2026 given the competition from Rust and the maturity of Java?
Yes, and the reasoning matters more than the yes. Go's position in cloud infrastructure is structural, not fashionable: Kubernetes, Docker, Terraform, and most of the CNCF landscape are Go codebases, so the platform engineering wave keeps generating demand. In India specifically, Go postings concentrate in fintech and high-scale consumer engineering, the tier that Razorpay, Zerodha, CRED, Swiggy, and Gojek recruit into, and that demand has held steady for years rather than spiking with a hype cycle. Rust wins where GC pauses or memory safety without GC are hard requirements, but its hiring pool and delivery speed still trail Go's for ordinary services. The honest positioning: Go is the highest demand-to-supply ratio among backend languages in India right now, easier to reach hireable depth than Rust, and better paid on average than equivalent Node or PHP roles.
How does Go compare with Java and Node.js for backend roles in India?
Java still has the largest absolute job volume, dominated by services companies (TCS, Infosys, Wipro) and BFSI enterprises, but that volume comes with commodity pay at the low end. Node.js dominates startups' early stacks and full-stack roles but suffers from an oversupplied talent pool. Go sits in the sweet spot: fewer openings than Java in absolute terms, but concentrated in product companies and infrastructure teams that pay better, and with far fewer qualified candidates per opening. Technically, Go's per-request memory footprint and concurrency model outclass Node for CPU-bound and highly concurrent work, while its cold start, single-binary deploys, and container density beat the JVM in Kubernetes-heavy environments. Many of the strongest Indian backend engineers run Java or Node as their base skill and add Go to unlock infrastructure and fintech roles.
Which Go projects on a resume actually impress interviewers?
Projects that demonstrate concurrency and production thinking, not CRUD. Strong choices: a rate limiter or job queue with worker pools, backpressure, and graceful shutdown; a CLI tool published with goreleaser that solves a real problem; a small distributed system (a key-value store with replication, a URL shortener with real caching) instrumented with pprof and slog; or meaningful contributions to Go infrastructure projects (Kubernetes operators, Terraform providers, Prometheus exporters), which double as proof you can read large Go codebases. Every project should have table-driven tests, a -race-clean CI run, and a Dockerfile with a distroless image, because those three signals map exactly to what interviewers check in code review rounds. One deep project beats five tutorials-with-different-names.
Introduction
Go owns the infrastructure layer of modern computing. Docker, Kubernetes, Terraform, Prometheus, etcd, and CockroachDB are all written in it, which means every company running cloud-native workloads eventually hires Go engineers. In India the language has moved well beyond infra tooling: payments, broking, and consumer-scale employers including Razorpay, Zerodha, CRED, Swiggy, Gojek, and Grab hire for Go out of their Bengaluru engineering offices, usually for high-throughput APIs, transaction flows, and internal platform tooling. Zerodha's engineering team writes publicly about the parts of its stack it builds in Go, which makes their blog one of the few first-hand Indian sources worth reading before an interview. The pitch has stayed consistent since 2009: a small language that compiles to a single static binary, starts in milliseconds, and makes concurrency a first-class citizen instead of a library bolted on later.
Go interviews are unusually predictable, and unusually deep. Almost every loop touches the same core: how goroutines differ from threads and what the GMP scheduler does with them, what a slice header actually contains and when append silently shares memory, channel semantics including the nil and closed edge cases, context propagation, and error wrapping with %w. Senior rounds add the runtime: escape analysis, the garbage collector, GOMEMLIMIT, pprof-driven debugging, and the changes that landed between Go 1.21 and 1.25, from the loop variable fix to container-aware GOMAXPROCS. Interviewers at Razorpay or Uber rarely ask trivia; they ask what your code does under load.
This guide contains 60 questions ordered basic to intermediate to advanced, written for the topics that decide real offers rather than the ones that pad listicles. Every technical answer names the concrete API, flag, or version behaviour involved, and more than half include runnable code. Work through the basic block to make the language semantics automatic, then spend most of your preparation time on the intermediate and advanced sections: goroutine leaks, slice aliasing bugs, production HTTP clients, profiling, and recent version changes are where candidates separate. Budget two to three weeks of focused practice if you already write another backend language daily.
Ready to practice Go interviews?
Don't just read, practice these Go questions live with an AI interviewer that asks follow-ups and scores your answers.