Go 1.22 shipped in February 2024 and quietly fixed a bug that had haunted interview candidates for a decade: loop variables. Before that release, every goroutine spawned inside a for loop shared the exact same variable, a classic gotcha interviewers loved testing. Half the time now, the bug simply doesn't exist anymore.
Golang interview questions keep evolving for the same reason: the language keeps moving. The 2025 Stack Overflow Developer Survey found that 14.4% of professional developers now use Go, and Go's own 2025 developer survey ties that growth specifically to backend, cloud infrastructure, and platform engineering roles. More teams are running Go-specific loops than they were two years ago.
Here's my honest opinion, and it might be wrong: half of the golang interview questions lists floating around in 2026 are generic programming questions wearing a Go costume. Every question below earns its place here because the answer genuinely depends on Go's runtime, scheduler, or type system, something Python or Java wouldn't test the same way.
Go Basics and the Type System
Before anything about goroutines comes up, most loops open with a few fundamentals questions to see if you actually know the language or just copy-pasted your way through a tutorial once.
Easy questions
15Yes. Every variable's type is fixed at compile time, and the compiler rejects code where types don't match, unlike Python or JavaScript, where you can reassign a string variable to hold an int later without complaint. Go also infers types with :=, so it feels less verbose than Java or C# while staying just as strict underneath.
Interviewers use this to check whether you understand compile-time versus runtime type checking, and it's often a lead-in to a follow-up about interfaces, since interfaces are where Go's type system gets genuinely interesting (more on that below).
Every variable declared without an explicit value gets a sensible default automatically: 0 for numeric types, an empty string for strings, false for booleans, and nil for pointers, slices, maps, channels, functions, and interfaces. Go guarantees this so var x int is never garbage memory the way an uninitialized variable can be in C.
The follow-up almost always targets structs: what's the zero value of a struct with five fields? Each field gets its own zero value, recursively. There's a sneakier version too: a nil slice and an empty slice ([]int{}) both have length 0, but comparing to nil only returns true for the nil one, and that distinction trips up a good number of candidates.
An array has a fixed length baked into its type, [5]int and [10]int are different types entirely, and arrays are copied by value when passed to a function. A slice is a small struct of a pointer, a length, and a capacity that points into an underlying array, and it gets passed around cheaply, closer to how you'd expect a general-purpose list to behave.
Most Go code you'll ever write uses slices. Arrays mostly show up as the backing store slices point to, or in narrow cases where fixed size really matters, hashing, wire protocols, that sort of thing.
A goroutine starts with a tiny stack, about 2KB, that the Go runtime grows and shrinks as needed, versus the megabyte-scale fixed stack an OS thread reserves. The Go scheduler multiplexes a large number of goroutines onto a much smaller number of OS threads, the "GMP" model of goroutines, machine threads, and logical processors, so spinning up tens of thousands of goroutines is routine, not reckless.
Interviewers use this to gauge whether you understand why Go concurrency is cheap compared to spawning OS threads directly in a language like Java. The follow-up usually asks what happens when a goroutine blocks on a syscall (the runtime detaches the OS thread and hands its processor to another thread so other goroutines keep running).
Return an error for anything expected and recoverable: a file that doesn't exist, a network timeout, bad user input. Reserve panic for programmer errors and genuinely unrecoverable states, an index out of range, a nil pointer dereference you didn't guard against, an invariant your code assumed would always hold.
Go's standard library follows this almost religiously. os.Open returns an error, it doesn't panic when a file is missing. Candidates who reach for panic as a general try/catch substitute usually haven't internalized that Go treats errors as ordinary values, not exceptional control flow.
Last in, first out. The most recently deferred call runs first, then the runtime works backward through the stack in the reverse order you wrote them.
func main() {
defer fmt.Println("first deferred")
defer fmt.Println("second deferred")
defer fmt.Println("third deferred")
fmt.Println("function body")
}
// prints, in order: function body, third deferred, second deferred, first deferredA simple question, but a good filter. Candidates who've only read about defer, rather than used it themselves to unlock a mutex or close a file, sometimes guess it runs in the order it was written.
GOPATH was the original dependency model. Every project had to live inside a single workspace tree (src/github.com/user/project), and there was no way to pin a specific dependency version in the repo itself, you got whatever was checked out under GOPATH/src at build time. Go modules, introduced in Go 1.11 and the default since Go 1.13, decouple your project from any fixed directory and record exact dependency versions in go.mod and go.sum. Two developers building the same commit now get the same dependency graph, and you can have multiple projects on one machine using different versions of the same library.
Practically, go mod init creates the module, go.mod tracks direct and indirect requirements with semantic versions, and go.sum stores cryptographic hashes so go build fails if a downloaded module doesn't match what was recorded. GOPATH still exists as an environment variable, it's where modules get cached under $GOPATH/pkg/mod, but your code no longer needs to live inside it.
A value receiver gets a copy of the struct when the method runs, so any field mutation inside the method is invisible to the caller. A pointer receiver gets the actual address, so mutations persist, and large structs avoid getting copied on every call.
The rule of thumb is that if any method on a type needs a pointer receiver, make all of them pointer receivers, for consistency, and because the method set differs. A value of type T only has the value-receiver methods, while *T has both. This matters for interface satisfaction, if an interface method is defined with a pointer receiver, only *T implements the interface, not T itself, and that's a common compile error people run into without understanding why.
new(T) allocates zeroed memory for a value of type T and returns a pointer to it, *T. It works for any type, but for slices, maps, and channels it isn't that useful, because a zeroed slice or map header without its internal machinery initialized isn't ready to use. make(T, args) exists specifically for slices, maps, and channels, it initializes the internal data structure, backing array, hash table, or channel buffer, and returns the value itself, not a pointer.
In practice you almost never call new() in idiomatic Go code, people write &T{} instead when they want a pointer to a zero-value struct. make() shows up constantly though: make([]int, 0, 10) for a slice with capacity, make(map[string]int) for a map, make(chan int, 5) for a buffered channel.
var s []int gives you a nil slice, its length and capacity are both zero, with no backing array. s := []int{} gives you an empty, non-nil slice, length and capacity are also zero, but it points at an allocated array. Both behave the same for len(), append(), and ranging over them, so functionally you rarely notice a difference day to day.
Where it bites people is JSON marshaling and equality checks. encoding/json marshals a nil slice as null and an empty slice as [], which matters if you're building an API contract someone else parses. And s == nil is only true for the nil slice, never for []int{}, so code that checks "no items" with == nil instead of len(s) == 0 will let an empty-but-non-nil slice slip through unnoticed.
A Go string is a read-only slice of bytes, and len(s) counts bytes, not characters. Go source and string literals are UTF-8 by convention, so any character outside plain ASCII, accented letters, CJK characters, emoji, takes more than one byte. "café" is 5 bytes in UTF-8 even though a person reading it sees 4 characters, because é encodes to two bytes.
If you range over a string with for...range, Go decodes UTF-8 for you and hands you rune values (int32) at each step, with the loop index jumping by however many bytes that rune took, not by 1. Index into a string directly, s[i], and you get a single byte, which for multi-byte characters isn't a valid character on its own. For accurate character counting you'd use utf8.RuneCountInString(s) or convert to []rune(s) and take the length of that.
iota is a predeclared identifier that resets to 0 at the start of every const block and increments by 1 with each line inside that block, whether or not you write it explicitly on every line. It's Go's answer to C-style enums, since Go has no real enum keyword.
const (
StatusPending = iota // 0
StatusActive // 1
StatusClosed // 2
)You can also use iota in expressions to build byte-size constants, 1 << (10 * iota) gives you KB, MB, GB across successive lines. The gotcha is that iota only increments per line inside the block, so skipping a line, or mixing in a different kind of statement, can shift the numbering in ways that surprise whoever reads the code later.
A variadic function takes a variable number of arguments of the same type, declared with ...T as the last parameter, like func Sum(nums ...int) int. Inside the function, nums behaves like a normal []int. Callers can pass individual arguments, Sum(1, 2, 3), or none at all.
If you already have a slice and want to pass its elements into a variadic function, spread it with the ... suffix at the call site: Sum(mySlice...). Without that ellipsis, Go tries to pass the whole slice as a single argument and fails to compile, since a []int isn't assignable to a variadic int parameter directly.
The underscore is a write-only placeholder. It satisfies Go's rule that every declared variable must be used, without creating a usable variable at all, the compiler discards it immediately.
Common uses: discarding a return value you don't care about, like _, err := os.Open(name) when you only want the error, or _ = someFunc() to explicitly signal you're ignoring a value on purpose. It shows up in for range loops when you only need the value, not the index, for _, v := range items. And it's used for side-effect-only imports, import _ "github.com/lib/pq", where you need a package's init() to run, registering a database driver, but never call the package by name directly.
Go has no exception mechanism for normal control flow. Functions that can fail return an extra value, conventionally last, of type error, and the caller checks it immediately: result, err := DoThing(); if err != nil { ... }. Failure paths become visible in the function signature and at every call site, instead of hiding inside a stack unwind that could originate several layers down.
The tradeoff is verbosity. Real Go code has a lot of if err != nil blocks, and people coming from Java or Python sometimes find it repetitive. But it forces a decision, right at the call site, about whether to handle, wrap, or propagate an error, and it avoids the situation where an exception thrown three functions down silently skips cleanup code you expected to run. panic and recover exist in Go, but they're reserved for genuinely unrecoverable situations, not expected error conditions like a missing file or a failed network call.
Medium questions
25A type conversion, float64(x), changes a value from one concrete type to another and is checked at compile time. A type assertion, x.(SomeType), extracts the concrete value out of an interface variable and is checked at runtime, and it can panic if you use the single-return form and guess wrong.
Interviewers want to hear that you default to the two-value form, val, ok := x.(SomeType), in production code, and reserve the panicking single-value form for cases where a wrong type genuinely means a programmer error you want to surface loudly.
Because slices share their underlying array. If the slice passed in has spare capacity, append writes into that same backing array and the caller sees the mutation, even though the function never returned anything to them. If there's no spare capacity, append allocates a brand new array behind the scenes, and the caller's original slice stays untouched. Same function call, two completely different outcomes, depending on capacity you can't see just by reading the code.
func addItem(items []int) []int {
return append(items, 99)
}
func main() {
original := make([]int, 3, 5) // len 3, cap 5
original[0], original[1], original[2] = 1, 2, 3
modified := addItem(original)
modified[0] = 100
fmt.Println(original) // [100 2 3]
fmt.Println(modified) // [100 2 3 99]
}This is, in my opinion, the single most underrated Go interview question. It doesn't require knowing anything obscure, just a clear mental model of len versus cap. The usual follow-up: how would you guarantee a function never mutates the caller's slice? Answer: copy explicitly, or pass a fresh slice built with append([]int{}, items...).
No. A concurrent write, or a write racing a read, triggers a runtime "concurrent map writes" panic that crashes the whole program. Go doesn't quietly corrupt the map, it kills the process so you notice immediately. If you need concurrent access, wrap the map in a sync.Mutex or sync.RWMutex, or reach for sync.Map for the narrow case where keys are mostly stable and access is read-heavy.
Interviewers use this to see whether you've actually hit this panic in real code, or only read about it somewhere. A decent number of candidates assume Go maps behave like Java's ConcurrentHashMap by default. They don't, and assuming otherwise is exactly the kind of thing that pages someone at 2 a.m.
Implicitly. If a type's method set includes every method an interface declares, it satisfies that interface automatically, with no explicit declaration anywhere. That's what people mean by structural typing in Go: you can define a small interface after the fact, around a type you don't even own, and it just works.
This usually turns into a design question: why is that useful? The honest answer is decoupling. A function that accepts io.Writer doesn't care whether the concrete type is a file, a buffer, or a network connection, and packages don't need to import each other just to satisfy an interface.
An unbuffered channel forces a synchronous handoff. The sender blocks until a receiver is ready to take the value, and vice versa. A buffered channel of size N lets up to N sends complete without a receiver present, decoupling the timing of the two sides, but it doesn't give the same happens-before guarantee that an unbuffered handoff does.
A lot of candidates assume buffered channels are simply "better" because they block less often. They're not automatically safer, they just move the blocking point further down the line. Once the buffer fills faster than it drains, you're back to blocking, just with extra memory sitting in between.
The simplest version: sending on an unbuffered channel from the same goroutine that's supposed to receive from it. There's no other goroutine to take the value, so the send blocks forever, and Go's runtime detects that every goroutine is asleep and panics with "fatal error: all goroutines are asleep - deadlock!"
func main() {
ch := make(chan int) // unbuffered
ch <- 42 // blocks forever, no receiver exists yet
fmt.Println(<-ch) // never reached
}The fix is either to receive in a separate goroutine, or size the channel to 1 if you only need a one-off handoff without strict synchronization. Interviewers extend this into asking about goroutine leaks: a blocked goroutine doesn't crash your program the way a deadlock does, it just sits there forever, holding memory, and those add up quietly in long-running services.
Go picks one at random, uniformly, among the ready cases. It isn't top-to-bottom priority order, and that surprises people coming from switch-statement intuition. If none of the cases are ready and there's a default case, select takes the default immediately instead of blocking.
select {
case msg := <-ch1:
fmt.Println("from ch1:", msg)
case msg := <-ch2:
fmt.Println("from ch2:", msg)
default:
fmt.Println("nothing ready yet")
}This comes up when candidates design a fan-in pattern or a timeout, a select with a time.After(...) case is the standard way to bound a channel receive. Knowing the random-selection detail matters when two channels are both usually ready at once, otherwise your code quietly starves one of them without you noticing.
Calling wg.Add(1) inside the goroutine instead of before go func() {...}(). Add and Wait need to happen in the same coordinating goroutine. If Add runs inside the new goroutine, Wait might execute before that goroutine even starts, see a count of zero, and return immediately while other goroutines are still running.
The rule interviewers want to hear back is simple: call Add before you spawn the goroutine, never inside it. It's a one-line fix, but forgetting it produces intermittent failures that only surface under load, which makes it a genuinely nasty one to debug once it's already in production.
errors.Is checks whether an error, or anything it wraps, matches a target, walking the chain created by %w in fmt.Errorf. A plain == comparison only matches the exact same error value, so it breaks the moment you wrap an error for context. errors.As does the equivalent for extracting a specific error type out of a wrapped chain so you can read its fields.
This question is really about whether you know Go added error wrapping in version 1.13 and actually use it, versus writing if err.Error() == "not found" string comparisons, which is fragile and, honestly, a code smell most interviewers will call out on sight.
recover() only works inside a deferred function, and it only stops the panic in the goroutine where it's called, a panic in one goroutine can't be recovered from another. Using it everywhere to swallow errors hides real bugs instead of fixing them, and a panic that gets silently recovered without logging is one of the harder classes of bug to track down later.
The legitimate use case interviewers expect you to name is a server that recovers panics per request, so one bad handler doesn't take down the whole process, while still logging the panic loudly. That's a narrow, deliberate use, not a habit of wrapping everything in recover.
A full garbage collector, specifically a concurrent, tri-color mark-and-sweep collector that runs alongside your program instead of stopping it for the whole cycle. Stop-the-world pauses still happen at the start and end of a cycle, but they're typically sub-millisecond, not the multi-second pauses people associate with older JVM collectors.
Candidates sometimes assume Go uses reference counting because that's what Python and Swift do. It doesn't, and the distinction matters if you're asked to reason about cyclic data structures. Reference counting struggles with cycles. Mark-and-sweep doesn't care either way.
GOGC sets the target heap growth percentage before the next GC cycle triggers. The default of 100 means the collector aims to run again once the live heap has roughly doubled. Setting GOGC higher, or disabling it entirely with GOGC=off, means fewer and later GC cycles, which cuts CPU time spent collecting at the cost of using noticeably more memory.
Interviewers who ask this usually want to hear the trade-off named explicitly, and it's worth mentioning GOMEMLIMIT, added in Go 1.19, which sets a hard memory ceiling independent of GOGC, useful inside containers with a fixed memory limit where you don't want the collector finding out the hard way.
Go's team shipped an experimental collector called Green Tea in Go 1.25, opt-in through GOEXPERIMENT=greenteagc, that scans memory in larger contiguous blocks instead of object by object, which cooperates far better with how modern CPU caches actually work. Go's own blog post on it reports 10 to 40% less time spent in garbage collection on some workloads, and the team has said they expect Green Tea to become the default in Go 1.26.
My guess is most interviewers haven't caught up to this yet, so don't expect it to come up unprompted. But mentioning it yourself, briefly, signals you're reading current release notes instead of a five-year-old blog post someone bookmarked once. That's a cheap way to stand out in a 45-minute loop.
Spin up a goroutine per input channel that forwards every value it reads into a shared output channel, then use a sync.WaitGroup to know when all of them have finished so the merged channel can be closed exactly once.
func fanIn(channels ...<-chan int) <-chan int {
merged := make(chan int)
var wg sync.WaitGroup
wg.Add(len(channels))
for _, ch := range channels {
go func(c <-chan int) {
defer wg.Done()
for v := range c {
merged <- v
}
}(ch)
}
go func() {
wg.Wait()
close(merged)
}()
return merged
}The gotcha interviewers watch for is the loop variable. Passing ch in as a parameter, rather than capturing the range variable directly inside the closure, sidesteps the classic shared-variable bug we talked about at the top of this page. Candidates who write it correctly without needing a hint usually get a small, unprompted nod from whoever is running the loop.
A slice header is three words, a pointer to a backing array, a length, and a capacity. When you append and the new length would exceed the current capacity, Go allocates a new, larger backing array, copies the existing elements over, and returns a slice header pointing at the new array. If there's still room within capacity, append just writes into the existing array and bumps the length, no allocation happens.
The growth factor has changed over Go's history and isn't a single fixed multiplier. It historically roughly doubled capacity for small slices and slowed to about 1.25x for larger ones, to avoid wasting memory as slices get big. The runtime also rounds the requested size up to fit the memory allocator's existing size classes, so cap() after repeated appends can look a little irregular if you print it. The practical takeaway for an interview: don't rely on exact growth numbers, but do know that pre-sizing with make([]T, 0, n) when you know roughly how many elements you'll append avoids repeated copies.
Before Go 1.22, a for loop declared its loop variable once and reused the same variable on every iteration. A goroutine or closure launched inside the loop body that referenced that variable directly captured the same shared variable as every other iteration, and by the time the goroutines actually ran, the loop had usually already finished, leaving every goroutine reading the final value.
for i, v := range items {
go func() {
fmt.Println(i, v) // pre-1.22: often prints the last i/v repeatedly
}()
}The usual fix was to shadow the variable inside the loop body, v := v before the closure, or pass it as an explicit argument, go func(v int) { ... }(v). Go 1.22 changed the semantics so for and range now create a new variable per iteration automatically, quietly fixing this whole class of bug for code built with that version or later. It's still worth knowing the old pattern, since a lot of production code predates 1.22 and still carries the manual shadowing fix, and this question separates people who've actually hit the bug from people who've only read about it.
Sending on a closed channel panics immediately, that's a hard rule, which is why the convention is that only the sender ever closes a channel, never the receiver, so a rogue goroutine doesn't write to something someone else already shut down. Receiving from a closed channel never blocks and never panics, it returns the zero value for the channel's type instantly, and the two-value form v, ok := <-ch tells you ok is false once the channel is drained and closed, so you can distinguish "closed" from "a real zero value was sent."
A nil channel, the zero value of a channel type that was declared but never made with make(), behaves differently again. Both sending and receiving on it block forever, no panic, no error, nothing. That sounds useless, but it's a deliberate select trick, if you want to temporarily disable one case in a select statement, you set that channel variable to nil and the case simply never becomes ready, without needing an if guard wrapped around the whole select.
sync.RWMutex lets any number of readers hold the lock simultaneously via RLock(), while a writer calling Lock() blocks until all current readers release, and blocks new readers or writers while it holds the lock itself. It's a clear win when reads vastly outnumber writes, a config cache read on every request but updated once a minute is the textbook case.
The risk is that RWMutex carries more overhead per lock and unlock than a plain Mutex, since it has to track a reader count atomically, so if your workload is actually write-heavy, or reads and writes are roughly balanced, a plain Mutex is often faster despite sounding less clever. There's a subtler risk too, writer starvation. If readers keep arriving faster than they finish, a writer waiting on Lock() can get pushed back, though Go's runtime does try to stop new readers from jumping the queue once a writer is already waiting. If you're not sure your read/write ratio justifies it, default to sync.Mutex and only switch after profiling shows lock contention on reads.
sync.Once guarantees that a function passed to its Do method runs exactly once, no matter how many goroutines call Do concurrently or how many times it's called overall. Every caller after the first blocks until the first call's function finishes, then all of them return without running it again.
var (
instance *Client
once sync.Once
)
func GetClient() *Client {
once.Do(func() {
instance = newClient()
})
return instance
}This is the idiomatic replacement for the double-checked locking pattern people write by hand in Java or C++. Without sync.Once you'd need a mutex plus a manual nil check, and it's easy to get the memory ordering wrong doing that yourself. One thing people miss: if the function passed to Do panics, Once still considers itself done, it won't retry on the next call, so error handling inside that initialization function needs to be airtight, or you end up with a permanently broken singleton and no way to recover short of restarting the process.
A context.Context is built as an immutable chain. context.WithCancel, context.WithTimeout, and context.WithDeadline all wrap a parent context and return a child plus a cancel function. Cancelling a parent, or its deadline firing, closes an internal channel that every descendant shares, and any goroutine selecting on ctx.Done() across that chain wakes up at once. That's how a single timeout set at the top of an HTTP handler can abort a database query three layers down, as long as every layer actually threads the context through and respects ctx.Done() or ctx.Err() in its blocking calls.
context.WithValue is the part people misuse. It's meant for request-scoped metadata that has to cross API boundaries you don't control, a trace ID or an auth token extracted by middleware. It's not meant as a general-purpose way to pass application parameters, since values are stored as an untyped interface{} keyed by another interface{}, so there's no compile-time safety, a typo in the key or a wrong type assertion fails silently or panics at runtime, and it makes a function's real dependencies invisible from its signature. If a function actually needs a value to do its job, pass it as a normal argument.
defer doesn't run when the loop iteration ends, it runs when the enclosing function returns. Open a file, a database connection, or a lock inside a loop and defer its Close() or Unlock() call, and all of those deferred calls pile up, only actually executing after the whole function exits, not after each iteration. Loop over a thousand files and defer file.Close() for each one, and you'll have a thousand open file descriptors sitting around until the function returns, which can exhaust the OS's file descriptor limit long before that.
for _, name := range filenames {
f, err := os.Open(name)
if err != nil {
return err
}
func() {
defer f.Close()
process(f)
}()
}The fix is wrapping the body that needs the deferred cleanup in its own function, an anonymous closure called immediately, or a named helper, so defer's scope is the closure, not the outer loop's enclosing function. Close() then runs at the end of every iteration instead of stacking up.
A type constraint is an interface listing which concrete types, or methods, or both, are allowed to satisfy a type parameter. Go's constraints package and the built-in any, an alias for interface{}, cover the common cases, and you can write your own, like a Number constraint that's a union of int, int64, float64, and so on using the | syntax inside an interface definition.
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](vals []T) T {
var total T
for _, v := range vals {
total += v
}
return total
}You reach for generics when you'd otherwise write the same logic multiple times for different concrete types, or fall back to interface{} plus type assertions or reflection, losing compile-time safety and adding runtime overhead. A plain interface is still the right tool when you're describing behavior, something that implements a Read() method, rather than describing a family of data types you want to operate on generically. If your function's logic doesn't change based on the type, and you'd just be duplicating a for loop for int, float64, and string versions, that's the signal to use a type parameter instead of three near-identical functions.
You run your tests, or the binary itself, with the -race flag, go test -race ./... or go run -race main.go. It instruments every memory access and every synchronization event, mutex lock and unlock, channel send and receive, goroutine creation, at compile time, and at runtime tracks, using a vector-clock-style algorithm, which goroutine last touched a memory location and under what happens-before relationship. If two goroutines touch the same location, at least one write is involved, and there's no synchronization establishing an order between them, it reports a race with both stack traces.
The catch is that -race only catches races that actually occur during that specific execution, it doesn't statically analyze every possible interleaving. A race that only triggers under a particular timing or load pattern can run clean in CI a hundred times and then show up in production. That's why race detection needs to be part of the regular test suite, accepting the real cost, roughly 5 to 10x slower with higher memory use, and why load testing with -race enabled catches things a quick unit test run won't. Nobody ships a production binary built with -race, it's a development and CI tool only.
fmt.Errorf("failed to open config: %v", err) formats the underlying error into the message as plain text, the resulting error is a brand new, unrelated value, there's no programmatic link back to the original. fmt.Errorf("failed to open config: %w", err) does the same formatting, but the returned error also implements an Unwrap() error method that returns the original err, creating a chain.
errors.Is and errors.As walk that chain by repeatedly calling Unwrap(), so errors.Is(wrappedErr, os.ErrNotExist) can still return true even though wrappedErr's message is a completely different string, as long as %w was used somewhere along the chain. Use %v instead, and that link is broken, errors.Is only matches if wrappedErr itself happens to equal the target, which it won't after formatting. Most teams land on using %w when a caller might reasonably need to check for a specific underlying error or sentinel value, and %v when you just want the message and don't want to expose the internal error type as part of your API's contract.
A goroutine leak happens when a goroutine blocks forever on something that will never happen, usually a channel send or receive with no corresponding partner, or a context that never gets cancelled. The classic case is a worker goroutine reading from a channel in a for range loop, where the channel is never closed and nothing sends to it again, so the goroutine sits there parked forever, and everything it's holding onto never gets garbage collected because the runtime still considers it reachable and runnable.
In production you'd first notice a slow, steady climb in the process's goroutine count and often its memory footprint, visible through runtime.NumGoroutine() if you're exporting it, or through pprof's goroutine profile. Hitting the /debug/pprof/goroutine endpoint on a service exposing net/http/pprof gives a full stack trace for every currently running goroutine grouped by where it's blocked, and thousands of goroutines all sitting at the exact same line waiting on a channel receive points straight at your leak. The fix is almost always giving the blocking operation an escape hatch, a context with a timeout, a select with a done channel, or making sure the channel actually gets closed when the producer side finishes.
Hard questions
12An interface value is really two words internally, a type and a value. When a nil *MyError gets assigned to an error interface, the interface's type field is set to *MyError even though the value itself is nil. Comparing that interface to nil checks both words, and the type word isn't nil, so the comparison returns false. This catches almost everyone at least once.
type MyError struct{}
func (e *MyError) Error() string { return "boom" }
func mayFail(fail bool) error {
var err *MyError
if fail {
err = &MyError{}
}
return err
}
func main() {
err := mayFail(false)
fmt.Println(err == nil) // false, not true
}The fix is to return a literal nil from functions when there's no error, never a typed nil pointer wrapped in a named return. Interviewers who ask this are checking whether you've been burned by it in real code, not whether you memorized the phrase "interface internal representation." I've watched senior candidates fumble this one. It's genuinely one of Go's sharper edges.
Spin up N worker goroutines that all range over the same jobs channel. Go automatically load-balances work across them since only one goroutine can receive any given value. Close the jobs channel once there's nothing left to send, that's what lets each worker's range loop exit cleanly.
func workerPool(jobs <-chan int, results chan<- int, workerCount int) {
var wg sync.WaitGroup
wg.Add(workerCount)
for i := 0; i < workerCount; i++ {
go func() {
defer wg.Done()
for j := range jobs {
results <- j * j
}
}()
}
wg.Wait()
close(results)
}The follow-up almost always asks what happens if a worker panics mid-job (it takes the whole program down unless you recover inside the goroutine), and whether results needs its own buffer so workers don't block on a slow consumer. Both are fair questions, and "I'd add a recover and log it instead of crashing" is a perfectly good answer.
If the return value is named, a deferred closure can modify it after the return statement runs but before the function actually hands control back to the caller. This is the mechanism behind the common pattern of recovering a panic and turning it into a returned error.
func increment() (result int) {
defer func() {
result++
}()
return 5
}
// increment() returns 6, not 5Interviewers ask this to see whether you know defer runs after the return value is assigned but before the function frame is torn down. It's subtle enough that even people who use defer daily are sometimes surprised the first time they see it written out.
Pair a map of keys to list elements for O(1) lookups with a container/list doubly linked list for O(1) reordering, and guard both with a single mutex. On Get, move the touched element to the front. On Put past capacity, evict from the back and delete the matching map entry.
package cache
import (
"container/list"
"sync"
)
type entry struct {
key, value int
}
type LRUCache struct {
mu sync.Mutex
cap int
items map[int]*list.Element
order *list.List
}
func NewLRUCache(capacity int) *LRUCache {
return &LRUCache{
cap: capacity,
items: make(map[int]*list.Element),
order: list.New(),
}
}
func (c *LRUCache) Get(key int) (int, bool) {
c.mu.Lock()
defer c.mu.Unlock()
el, ok := c.items[key]
if !ok {
return 0, false
}
c.order.MoveToFront(el)
return el.Value.(*entry).value, true
}
func (c *LRUCache) Put(key, value int) {
c.mu.Lock()
defer c.mu.Unlock()
if el, ok := c.items[key]; ok {
el.Value.(*entry).value = value
c.order.MoveToFront(el)
return
}
if c.order.Len() >= c.cap {
oldest := c.order.Back()
if oldest != nil {
c.order.Remove(oldest)
delete(c.items, oldest.Value.(*entry).key)
}
}
el := c.order.PushFront(&entry{key: key, value: value})
c.items[key] = el
}Interviewers extend this the same way they would in Python or Java, ask about thread safety first, then ask what changes if the cache needs to hold up under heavy concurrent traffic across many goroutines. The honest answer at that point is usually to shard the cache into several buckets by key hash and give each bucket its own mutex, which is a real technique, not a hand-wave.
G is a goroutine, a lightweight unit of work with its own small, growable stack starting around 2KB. M is a machine, an actual OS thread. P is a processor, a scheduling context holding a local run queue of goroutines, required for an M to execute Go code. The number of Ps is set by GOMAXPROCS and defaults to the number of CPU cores. An M can only run Go code while it holds a P, and there are usually more Ms than Ps around because Ms get created or parked as goroutines block on system calls.
Each P has its own local run queue, so most scheduling decisions don't need a global lock, an M just pops the next goroutine off its P's queue and runs it. There's also a global run queue for overflow. When a P's local queue runs dry, instead of sitting idle, it steals half the goroutines from another P's queue, that's work stealing, and it keeps CPU cores busy even when load across goroutines is uneven. When a goroutine makes a blocking system call, the runtime detects that and hands the P off to a different M so other goroutines assigned to that P aren't stuck waiting on one blocked syscall, then reattaches or creates a new M once the syscall returns. This design is why you can spin up hundreds of thousands of goroutines on an 8-core machine without the OS thread count exploding.
The compiler aligns each field to its own size, a rule enforced by the target architecture, an int64 must start at an address divisible by 8, a bool can start anywhere, and it doesn't reorder your fields for you, it lays them out in the exact order you declared them. If a smaller field follows a larger one and doesn't fill the gap needed for the next field's alignment, the compiler inserts padding bytes, invisible in the source but real in memory.
type Bad struct {
A bool // 1 byte, then 7 bytes padding
B int64 // 8 bytes
C bool // 1 byte, then 7 bytes padding
}
// size: 24 bytes
type Good struct {
B int64 // 8 bytes
A bool // 1 byte
C bool // 1 byte, 6 bytes padding at the end
}
// size: 16 bytesThe fix is simple once you know the rule, order fields from largest alignment requirement to smallest, generally 8-byte fields first (int64, float64, pointers, slices, maps, interfaces, since those are multi-word), then 4-byte, then smaller types like bool and byte at the end. You can check this yourself with unsafe.Sizeof on the struct, or with the fieldalignment tool that ships with golang.org/x/tools, which flags structs where reordering would save space. This matters most when a struct type gets allocated millions of times, a log line, a cache entry, a protobuf-adjacent DTO, where a few bytes of padding per instance adds up to real memory and worse cache behavior at scale.
Each goroutine has its own stack and its own panic and defer machinery. recover() only works when called directly by a deferred function running on the same goroutine where the panic originated. A panic not recovered anywhere on its own goroutine's call stack crashes the entire process, it doesn't matter if the main goroutine or some other goroutine has a perfectly good recover() set up, that recover() is simply never in the call stack that's unwinding.
This means if you spin off a goroutine with go doSomething() and doSomething panics without its own recover, the whole program goes down, taking every other goroutine with it, regardless of what error handling exists elsewhere. The fix is that every goroutine you launch needs its own defer and recover if it's doing anything that could panic, typically at the top of the function: defer func() { if r := recover(); r != nil { log the panic, maybe report it, but don't try to keep running the corrupted goroutine as if nothing happened } }(). Some teams build a small wrapper that automatically wraps the function passed to go with this pattern, which is a reasonable thing to keep in a shared internal package for anyone spawning a lot of background workers.
When you take a sub-slice, b := a[2:4], b doesn't get its own backing array, it points into the same underlying array as a at a different offset, and it inherits whatever capacity is left in that array past its own length. Append to b, and if b's capacity, not its length, still has room, append writes into that shared array in place rather than allocating a new one, silently overwriting elements of a that live past b's original window, even though you never touched a directly.
a := []int{1, 2, 3, 4, 5}
b := a[1:3] // b = [2, 3], cap(b) = 4 (shares a's backing array)
b = append(b, 99) // fits within capacity, writes into a's array
fmt.Println(a) // [1 2 3 99 5], a[3] changed silentlyThis is one of the nastier Go bugs because it doesn't show up as a compile error or even a panic, it just quietly corrupts data somewhere else in the program, and the bug report usually looks unrelated to slicing at all. It commonly bites people who pass a sub-slice into a function expecting it to build up its own independent list by appending, when in fact its appends silently mutate the caller's original array whenever capacity allows it. The fix is to copy explicitly, append([]int{}, b...) or the copy() builtin into a freshly made slice, when you need real independence, or re-slice with a third index to cap capacity at the length you intend, a[1:3:3], which forces any subsequent append on that sub-slice to always allocate a new array instead of writing into the shared one.
time.After(d) creates a new timer and returns a channel that fires once after duration d, but the timer isn't released and garbage collected until it actually fires. Call time.After() inside a loop body, say inside a select running on every iteration of a long-lived worker loop, and most iterations take some other path, a message arrives before the timeout, for instance, and you're leaving behind a live timer, still ticking down in the runtime's internal timer heap, on every iteration where the timeout case wasn't the one that fired.
Under low request volume you'd never notice, the timers eventually fire and get cleaned up. Under high volume, in a hot loop running thousands of times a second, you can accumulate pending timers faster than they expire, showing up as steadily climbing memory and increasing CPU spent in the runtime's timer management. The fix is creating the timer once, outside the hot path, with time.NewTimer(d), reusing it across iterations, and calling Reset() on it, after correctly draining its channel if it already fired, instead of allocating a fresh one every pass through the loop. For a one-shot timeout that only runs once per function call rather than in a loop, time.After() is completely fine, the leak only matters when it's called repeatedly in a tight loop or a select that itself gets re-entered often.
unsafe.Pointer lets you bypass Go's type system to reinterpret memory directly, converting between pointer types the compiler would otherwise reject, doing pointer arithmetic, or reading a struct's raw layout. The legitimate use cases are narrow: zero-copy conversions between []byte and string in a hot path (the standard library already does this internally in a few places, so you rarely need to hand-roll it), interfacing with C code through cgo where you need to match C's memory layout exactly, or writing performance-sensitive serialization code where an allocation per call is genuinely unacceptable.
The risk is that unsafe code opts out of every guarantee the type system and garbage collector normally give you. There are strict rules about which pointer conversions are valid, documented directly in the unsafe package, and one of the easiest to violate is converting a pointer to uintptr and holding onto that uintptr value across a function call or a point where the goroutine's stack might grow. Go's stack-copying growth mechanism updates real pointers automatically when it moves a stack, but a uintptr just looks like a plain integer to the runtime, so it never gets updated, and you're left holding an address that no longer points at your data. Code using unsafe also isn't portable across Go versions without re-verification, since the standard library reserves the right to change internal struct layouts between releases. Most teams gate unsafe usage behind a linter rule or a documented convention requiring a comment explaining exactly why it's needed and what invariant is being relied on, because six months later nobody remembers.
Go has no keyword for stack versus heap, the compiler's escape analysis pass decides automatically based on whether a value's lifetime or visibility could outlive the function that created it. If a local variable's address never leaves the function, never returned, never stored somewhere with a longer lifetime, never passed to something the compiler can't fully see through, it stays on the stack, cheap to allocate and reclaimed automatically when the function returns, no GC involvement needed. If the compiler can't prove that, the value escapes to the heap, slower to allocate and adding pressure on the garbage collector.
Common ways to force an escape: returning a pointer to a local variable, storing a value in a slice or map that's returned or passed somewhere longer-lived, or passing a value through an interface, since interfaces box the underlying value and the compiler generally can't prove the interface won't outlive the function, defaulting to heap allocation. You can see exactly what the compiler decided with go build -gcflags="-m" ./..., which prints a line like "moved to heap: x" for every variable, along with why. This is a real, practical optimization tool, not just trivia, if you're profiling a hot path and find unexpected heap allocations, the -m output tells you exactly which line and variable is causing it, and often a small refactor, passing a struct by value instead of returning a pointer to a local, or avoiding an unnecessary interface conversion, is enough to keep something on the stack instead.
First I'd confirm it's an actual leak and not just the GC being lazy about returning memory to the OS, Go doesn't do that aggressively by default, so a sawtooth pattern that plateaus is normal, a straight line up with no plateau is the real signal. I'd expose net/http/pprof, hit /debug/pprof/heap at two points in time, an hour or a day apart, and diff them with go tool pprof -base to see exactly which allocation sites grew between the two snapshots instead of guessing.
The usual suspects, in order of how often I've seen them: a goroutine leak where each stuck goroutine holds a reference to something large, check /debug/pprof/goroutine alongside the heap profile, a rising goroutine count tracking the memory graph is a strong signal, an unbounded in-memory cache or map that never evicts, and slice or string sub-slicing that retains a reference to a much larger backing array than the code actually needs, a small subs := largeSlice[10:15] keeps the entire underlying array alive as long as subs is reachable. I'd also check for time.After() calls inside loops or long-lived select statements, each one allocates a timer that isn't cleaned up until it fires, which adds up under high request volume. Once the heap diff points at a specific allocation site, it's usually obvious within minutes which of these it actually is.
How to Prepare for Golang Interview Questions
Read the standard library before you read a prep guide. container/list, sync, and context are short enough to read start to finish in an evening, and interviewers pull questions from exactly these packages more often than from anything you'll find in a random top-50 listicle.
Practice explaining your reasoning out loud instead of typing code silently. Across the Go-focused mock interview sessions candidates run on LastRoundAI, the failures that show up most often aren't syntax mistakes, they're candidates going quiet the moment a follow-up touches something like nil interfaces or channel deadlocks. The syntax was fine. The reasoning under a follow-up wasn't.
If you're interviewing for a broader systems role rather than a purely Go-specific one, it's worth cross-training on general algorithmic prep too. Our Google L4 interview guide and Uber interview guide cover the coding-round side that a lot of Go-heavy backend roles still test alongside the language-specific questions above.
I don't have a clean number for how often Go interviews skip system design entirely and go straight into a concurrency deep-dive instead. Anecdotally it happens more at infrastructure-heavy startups than at larger companies, but that's a pattern, not a rule, so don't skip system design prep on a hunch alone.
Practice Saying These Answers Out Loud
The questions above cover what actually gets asked. The harder part is answering them fluidly, under mild pressure, with someone watching and typing notes. That's the part a static list can't help with.
LastRoundAI's mock interview practice runs Go-specific sessions built around exactly these topics, goroutine leaks, nil interfaces, GC trade-offs, with follow-up questions generated live instead of scripted in advance. The free plan includes 15 credits a month that reset monthly, and the Starter plan runs $19 a month if you want more sessions before a real loop. If you're already applying and just need to cover more ground, Auto-Apply handles the volume side of the job search so your prep time goes toward the parts only you can practice.
Questions about either product go to contact@lastroundai.com. Nobody else answers that inbox, so you'll actually hear back.

