This page is for anyone facing a Go round, from a first backend job to a senior role. Most Go interviews start with goroutines and channels, then test select, the sync package and context cancellation, and move on to interfaces, struct embedding, slices and maps. Stronger rounds add error wrapping, defer and recover, generics, table-driven tests, the race detector and how the garbage collector works. Senior rounds add a production story and a judgement call. Each question shows what the interviewer is really checking, the shape of a strong answer and a short answer you can say out loud. Practise saying them, then swap in your own stories.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Cost: a goroutine starts with a small stack of a few kilobytes that grows and shrinks as needed.
Scheduling: the Go runtime multiplexes many goroutines onto a few OS threads in user space.
Control: no IDs, no way to kill one from outside; you stop it by signalling it, usually with a context or a channel.
"A goroutine is a function running concurrently, managed by the Go runtime rather than the operating system. It starts with a tiny stack, a few kilobytes, that the runtime grows and shrinks, whereas an OS thread usually reserves a much bigger fixed stack. The runtime schedules many goroutines onto a small pool of OS threads, so switching between them is cheap and doesn't need the kernel. That's why spinning up ten thousand goroutines for ten thousand connections is normal in Go. The trade-off is you get less control: there's no goroutine ID and no way to kill one from outside. If I want a goroutine to stop, I design it to listen for cancellation, usually a context or a done channel. And when main returns, the program exits and every other goroutine just stops, finished or not."
Saying each goroutine is its own OS thread, or that you can stop a goroutine from outside without its cooperation.
G, M, P: G is a goroutine, M is an OS thread, P is a processor slot with its own run queue; an M needs a P to run Go code.
GOMAXPROCS: sets the number of Ps, so how many goroutines run Go code in parallel.
Balancing: an idle P steals work from other Ps; there is also a global queue.
Blocking: a blocking syscall hands the P to another thread; network waits park the goroutine on the netpoller instead.
"Go uses three pieces. G is a goroutine, M is an OS thread, and P is a logical processor that holds a local queue of runnable goroutines. To run Go code, an M has to hold a P, and the number of Ps is GOMAXPROCS, which defaults to the number of CPUs the process can use, so that's how many goroutines truly run in parallel. When a P's queue runs dry, it steals work from another P, and there's a global queue as a fallback. If a goroutine makes a blocking system call, its thread blocks, so the runtime hands that P to another thread and the rest keep running. Network I/O is smarter: the goroutine is parked on the netpoller and no thread is tied up. And since the runtime can preempt goroutines asynchronously, a tight loop no longer starves everyone else."
Describing goroutines as green threads with no mention of Ps, or claiming GOMAXPROCS limits the total number of goroutines.
Cause: every goroutine sends on an unbuffered channel but only one value is received; the rest block forever.
Cost: blocked goroutines are never collected, so memory and goroutine count grow with each call.
Fix: a buffer big enough for every sender, or a select on a cancelled context so senders can give up.
"The channel is unbuffered and there's only one receive. The first goroutine to finish hands over its result, and every other goroutine blocks forever on its send, because nobody will ever read. The garbage collector can't free a blocked goroutine, so each call leaks len(urls) minus one goroutines, plus whatever they hold on to. The simplest fix is to buffer the channel to len(urls), so every send succeeds and the goroutines exit. The better fix also stops wasted work: pass a context, cancel it once I have my answer, and have each sender select between sending and ctx.Done. To catch this class of bug, I watch the goroutine count as a metric, and in tests I check that no goroutines are left behind after the function returns."
func first(urls []string) string {
ch := make(chan string) // unbuffered
for _, u := range urls {
go func(u string) { ch <- fetch(u) }(u)
}
return <-ch // one receive: the other senders block forever
}
// Fix: ch := make(chan string, len(urls))
Thinking the garbage collector will clean up the blocked goroutines once the function returns.
Stabilise: check the headroom, and plan a rolling restart before it runs out of memory.
Evidence: capture a goroutine profile first and group goroutines by the stack they are parked on.
Cause and fix: usually a missing timeout, an unread channel or an unclosed response body; fix it and add a leak check to tests.
"First I'd check how long until it runs out of memory and line up a rolling restart as a safety net, but before restarting any instance I'd grab a goroutine profile from the pprof endpoint, because a restart wipes the evidence. In that dump I look at which stacks have thousands of goroutines. A leak is nearly always thousands parked in the same place: a channel send nobody reads, a select with no ctx.Done case, or an outbound HTTP call with no timeout waiting on a slow dependency. I'd also check whether it lines up with a deploy or a dependency slowing down. Then the fix matches the cause: a context with a deadline, a buffered channel, or closing response bodies. Afterwards I'd add a test that checks no goroutines are left over, and an alert on the goroutine count trend."
Restarting the service on a schedule and calling it fixed, or restarting before capturing any profile.
Unbuffered: a send waits until a receiver takes the value; both sides meet at that moment.
Buffered: a send only blocks when the buffer is full; a receive blocks when it is empty.
Sizing: zero by default; a buffer should match a known count or smooth a known burst, not hide a bug.
"An unbuffered channel has no storage, so a send blocks until another goroutine receives. That makes it a handoff and a sync point: when my send returns, I know the receiver has the value. A buffered channel has a queue of a fixed size. Sends go straight in until it's full, and receives only block when it's empty, so producer and consumer are decoupled for a while. I start with unbuffered unless I have a reason. Good reasons are a known number of results, like one slot per goroutine so none of them ever block, or smoothing short bursts. What I avoid is bumping the buffer to make a hang go away. If the consumer is slower than the producer, a buffer only delays the moment it fills up."
Treating a bigger buffer as the fix for a deadlock or a slow consumer.
Waits on many: select blocks until one of its cases can proceed; if several are ready it picks one at random.
Timeout: add a case on a timer channel or on ctx.Done.
Non-blocking: a default case runs at once when no other case is ready.
"select is like a switch for channel operations. It blocks until one of its cases can go ahead, and if several are ready at the same moment it picks one at random, so no case is always favoured. For a timeout, I add a second case that fires later: time.After for a quick one-off, or better, ctx.Done so the caller controls the deadline and cancellation arrives through the same path. For a non-blocking send, I add a default case. If the channel can't take the value right now, default runs instead of waiting, which is handy for something like dropping metrics events when a buffer is full rather than slowing the request. One thing I watch is using default inside a loop with nothing else to block on, because that spins a CPU at full speed."
select {
case res := <-results:
return res, nil
case <-ctx.Done():
return Result{}, ctx.Err()
case <-time.After(2 * time.Second):
return Result{}, errors.New("timed out")
}
// non-blocking send
select {
case events <- e:
default:
dropped++ // buffer full: don't block the caller
}
Believing select checks cases top to bottom and always prefers the first ready one.
Owner closes: the sender closes, never the receiver; with many senders, close after they all finish.
Closed channel: send panics, close panics, receive drains the buffer then returns the zero value with ok false.
Nil channel: send and receive block forever; close panics.
"The rule I follow is that the side that sends owns the channel and is the only one allowed to close it. Closing is a message that says no more values are coming, and a receiver can't know that. If there are several senders, I have them finish under a WaitGroup and a separate goroutine closes the channel after Wait. Then the edge cases. Sending on a closed channel panics, and so does closing it twice. Receiving from a closed channel never blocks: you get any values still in the buffer, then the zero value with ok set to false, which is also what ends a for range loop. A nil channel is different: sends and receives block forever and closing it panics. That sounds useless, but in a select, setting a channel to nil switches that case off."
Closing a channel from the receiving side, or saying a receive on a closed channel blocks or panics.
Workers: start N goroutines that range over a jobs channel and send to a results channel.
Feeding: send the jobs from their own goroutine, then close the jobs channel.
Closing results: a goroutine waits on the WaitGroup, then closes results so the collector loop ends.
"I'd have two channels, one for jobs and one for results. I start N workers; each one ranges over the jobs channel, handles a job and sends the result on. A WaitGroup counts the workers. I feed the jobs from a separate goroutine and close the jobs channel when they're all sent, which is what makes each worker's range loop end. The trickiest part is closing results: I can't close it while workers might still send, so another goroutine waits on the WaitGroup and then closes it. Meanwhile the main goroutine ranges over results and collects them. The order of the results isn't the order of the jobs, so if order matters I'd carry an index in the job. In a real service I'd also pass a context so the whole pool stops if the caller gives up."
func process(jobs []Job, workers int) []Result {
in, out := make(chan Job), make(chan Result)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range in {
out <- handle(j)
}
}()
}
go func() {
for _, j := range jobs {
in <- j
}
close(in)
}()
go func() { wg.Wait(); close(out) }()
var results []Result
for r := range out {
results = append(results, r)
}
return results
}
Closing the results channel inside a worker, or collecting results in the same goroutine that is still blocked sending jobs.
Channels: for passing ownership of data, pipelines and signalling between goroutines.
Mutex: for guarding shared state like a cache, a counter or a struct several goroutines touch.
Variants: RWMutex for read-heavy data, sync/atomic for a single counter or flag.
Habits: keep the locked section small, never copy a Mutex, never block while holding one.
"I use channels when data is moving between goroutines: a pipeline, handing work to a pool, or signalling that something finished. I use a Mutex when several goroutines just need safe access to one piece of shared state, like an in-memory cache or a map of sessions. Building that with a channel means a goroutine that owns the map and a request-reply protocol, which is more code and usually slower. If reads far outnumber writes, an RWMutex lets readers go together, and for a single counter or flag I'd use sync/atomic. The habits that matter: lock, do the minimum, unlock, usually with defer. Never hold a lock while doing I/O or sending on a channel. And never copy a struct that holds a Mutex; go vet flags that for me."
Saying Mutex is un-idiomatic in Go and should always be replaced with channels.
API: Add before starting work, Done when each goroutine ends, Wait blocks until the count hits zero.
Mistake one: calling Add inside the new goroutine, so Wait can return before it runs.
Mistake two: passing the WaitGroup by value, so Done hits a copy; pass a pointer.
"A WaitGroup is a counter. I call Add before I start each goroutine, the goroutine calls Done when it finishes, usually with defer so it still runs on an early return, and Wait blocks until the counter reaches zero. The most common mistake is calling Add inside the goroutine. The goroutine might not have been scheduled yet when the parent reaches Wait, the counter is still zero, and Wait returns straight away while work is still running. So Add always happens in the parent, before the go statement. The second mistake is passing a WaitGroup to a function by value. Done then decrements a copy, and the real Wait hangs forever. I pass a pointer or capture it in a closure. Newer Go versions also have wg.Go, which does the Add and Done for me. And if I also need the first error back, I reach for errgroup instead."
Calling wg.Add(1) as the first line inside the goroutine and not seeing the race.
Guarantee: Do runs the function once, however many goroutines call it; the others wait until it returns.
Use: lazy setup of something costly and shared: a client, a parsed template, a config.
Catch: a failure is not retried; if the setup can fail, keep the error next to the value.
"sync.Once has one method, Do. However many goroutines call Do at the same time, the function inside runs exactly once, and every other caller blocks until that first run has finished, so they all see the result. I use it for lazy, shared setup: creating a client the first time it's needed, compiling a set of templates, or loading config in a package that shouldn't do work at import time. The catch is that Once counts the call as done even if the function failed or panicked, so there's no retry. If setup can fail, I store the error alongside the value so every caller sees it, and the newer helpers sync.OnceValue and OnceValues make that pattern short. Also, calling Do on the same Once from inside its own function deadlocks."
var getDB = sync.OnceValues(func() (*sql.DB, error) {
return sql.Open("postgres", dsn)
})
db, err := getDB() // opens once; every caller gets the same pair
Building lazy init with an unguarded nil check, or assuming Once will retry after an error.
Tree: each derived context is a child; cancelling a parent cancels all its children.
Respecting it: check ctx.Done in loops and selects, and pass ctx down to every blocking call.
Hygiene: always call cancel, ctx is the first parameter, never stored in a struct; values only for request-scoped data.
"A context carries a cancel signal and maybe a deadline down a call chain. I derive children with WithCancel, WithTimeout or WithDeadline, and cancelling a parent cancels every child, so when an HTTP client disconnects, the database query and the downstream calls all get told to stop. Cancellation is cooperative, though: nothing is killed. My code has to notice. In a loop I select on ctx.Done, return ctx.Err, which tells me whether it was cancelled or timed out, and I pass ctx into every call that can block. When I create a context with a cancel function I always defer cancel, even if it'll time out anyway, because that frees its resources early. Context is the first parameter, not a struct field, and WithValue is only for request-scoped things like a trace ID, never optional arguments."
func poll(ctx context.Context, every time.Duration) error {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err() // Canceled or DeadlineExceeded
case <-t.C:
if err := check(ctx); err != nil {
return err
}
}
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := poll(ctx, time.Second)
Thinking cancelling a context forcibly stops the goroutines using it.
Tool: errgroup.WithContext gives a group plus a context that is cancelled on the first error.
Results: each goroutine writes to its own slice index, so no lock is needed.
Wait: Wait blocks for all goroutines and returns the first error; SetLimit caps concurrency.
"I'd use errgroup from the golang.org/x/sync module. WithContext gives me a group and a derived context. I start each fetch with g.Go, passing that context into the fetch. The first goroutine that returns an error cancels the context, so the others see it and give up early, as long as fetch respects the context. Wait blocks until every goroutine has returned and gives me the first error. For results, I size a slice up front and each goroutine writes only to its own index. Different indexes are different memory, so there's no race and no lock. If the list could be long, I call SetLimit so I don't open a thousand connections at once. The only thing to remember is that a goroutine ignoring ctx keeps running to the end, so cancellation is only as good as the code under it."
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(4)
items := make([]Item, len(ids))
for i, id := range ids {
g.Go(func() error {
item, err := fetch(ctx, id)
if err != nil {
return err
}
items[i] = item // own index: no lock needed
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return items, nil
Appending to one shared slice from every goroutine without a lock, or not passing the derived context into the fetch.
Implicit: any type with the right method set satisfies the interface; there is no implements keyword.
Small: good interfaces have one or two methods, like io.Reader and io.Writer.
Placement: define the interface where it is consumed, accept interfaces and return concrete types.
"An interface in Go is just a set of method signatures. Any type that has those methods satisfies it automatically. There's no implements keyword, so the type doesn't even need to know the interface exists. That changes how you design. Instead of a big interface declared next to the implementation, the consumer defines a small one with only the methods it needs. If my function only reads bytes, it takes an io.Reader, and it works with files, network connections, buffers and anything else, including test fakes, without those types changing. The usual advice is to accept interfaces and return concrete structs, so callers get the full type. If I want the compiler to confirm a type satisfies an interface, I add a line like var _ io.Writer = (*MyWriter)(nil), which fails to compile if it doesn't."
Describing Go interfaces like Java ones, with explicit declarations and large interfaces owned by the implementer.
Two parts: an interface value holds a dynamic type and a value.
Nil rule: it equals nil only when both parts are nil; a typed nil pointer fills the type part.
Fix: return a literal nil for the no-error case; never return a typed pointer variable as error.
"An interface value is really a pair: the concrete type and the value. It's only equal to nil when both halves are empty. In this function, the variable is a nil pointer of type star MyErr. When it's returned as an error, the interface gets the type star MyErr and a nil value. The type half isn't empty, so the error isn't nil, and the caller thinks something failed. If they then call Error on it, it might even work, because a method with a pointer receiver can run on a nil pointer, which makes it more confusing. The fix is simple: in the success path, return nil directly, not a typed variable that happens to be nil. I also keep error-returning functions declared as returning error, never a concrete error type."
type MyErr struct{}
func (*MyErr) Error() string { return "boom" }
func check() error {
var p *MyErr // nil pointer
return p // error holds (type *MyErr, value nil)
}
func main() {
fmt.Println(check() == nil) // false
}
Saying a nil pointer inside an interface is the same as a nil interface.
Promotion: the embedded type's fields and methods can be used directly on the outer type.
Interfaces: promoted methods count, so the outer type can satisfy interfaces through them.
Not inheritance: the promoted method's receiver is still the inner value; it cannot call the outer type's version of a method.
"Embedding means putting a type inside a struct without a field name. Its fields and methods are promoted, so if Server embeds Logger, I can call s.Log directly, and Server satisfies any interface Logger's methods satisfy. It looks like inheritance but it's composition. When I call s.Log, Go really calls s.Logger.Log, and inside that method the receiver is the Logger, not the Server. So if Logger.Log calls another Logger method that Server also defines, it gets Logger's version, not Server's. There's no virtual dispatch. The outer type can define a method with the same name to shadow the inner one, and if two embedded types at the same depth have the same method, that's only a compile error when you actually call it. I use embedding to reuse behaviour, and interfaces when I need polymorphism."
type Logger struct{ prefix string }
func (l Logger) Log(msg string) { fmt.Println(l.prefix + msg) }
type Server struct {
Logger // embedded: s.Log("up") works
addr string
}
Expecting a method on the embedded type to call the outer type's override, as it would with virtual methods.
Pointer when: the method changes the receiver, the struct is large, or it holds something that must not be copied like a Mutex.
Consistency: if any method needs a pointer, make them all pointers.
Method sets: *T has both kinds of methods, T has only value-receiver ones, so only *T satisfies an interface needing a pointer method.
"A value receiver gets a copy, so changes inside the method are lost. I use a pointer receiver when the method has to modify the struct, when the struct is big enough that copying it on every call is wasteful, or when it contains something that mustn't be copied, like a Mutex. Small immutable types, like a time value or a point, work fine with value receivers. For consistency, if one method needs a pointer I make them all pointers. The interface part is about method sets. The pointer type has every method, value and pointer ones. The value type only has the value-receiver methods. So if Save has a pointer receiver, a Store value doesn't satisfy an interface with Save, but a pointer to Store does. That's the classic does-not-implement error, and the fix is usually to pass the address."
Saying pointer receivers are always faster, or not knowing why a value of T fails to satisfy an interface.
Good fit: the same logic over many types: containers, slice and map helpers, numeric utilities.
Constraints: an interface lists allowed types; the tilde includes types with that underlying type; comparable allows ==.
Not a fit: when behaviour differs per type, a normal interface is simpler.
"I use generics when the logic is identical and only the type changes: a set, a cache, a helper that filters or maps a slice, or summing numbers. Before generics you either copied the function per type or used any and lost type safety. A constraint is an interface that says which types are allowed. In this Number constraint, the tilde means any type whose underlying type is int, int64 or float64, so my own type Celsius based on float64 works too. Because every type in the set supports plus, the compiler lets me use it. For map keys I'd use comparable. Filter uses any, since it only moves values around. Where I don't use generics is when each type behaves differently. That's what interfaces are for, and forcing generics there just makes the code harder to read."
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](xs []T) T {
var total T
for _, x := range xs {
total += x
}
return total
}
func Filter[T any](xs []T, keep func(T) bool) []T {
var out []T
for _, x := range xs {
if keep(x) {
out = append(out, x)
}
}
return out
}
Using generics as a replacement for interfaces everywhere, or not knowing what a constraint is for.
Array: fixed length that is part of its type; assigning or passing it copies every element.
Slice: a header with a pointer to a backing array, a length and a capacity.
Passing: passing a slice copies the header only, so both sides share the same elements.
"An array has a fixed size that's part of its type, so an array of three ints and an array of four ints are different types. Arrays are values: assign one or pass it to a function and every element is copied. A slice is what we use almost all the time. It's a small header with three things: a pointer into a backing array, a length, which is how many elements I can see, and a capacity, which is how far the backing array goes. When I pass a slice to a function, only that header is copied, so the function can change the elements and I'll see it. But if the function appends and the slice has to grow, it may get a new array, and my copy of the header won't know. That's why append returns a slice you must use."
Saying slices are passed by reference like a pointer, or that arrays and slices are the same thing.
Room left: if len is less than cap, append writes into the same backing array and returns a longer view.
Growth: when capacity runs out, a bigger array is allocated, elements copied, and the new slice points there.
Avoid aliasing: use a full slice expression like a[:len(a):len(a)] or clone before appending.
"It prints 5 5. The slice a has length 3 but capacity 10. The first append has room, so it writes 4 into index 3 of the same backing array and b is a view of length 4 over that array. The second append also starts from a, still length 3, so it writes 5 into the same index 3. Now b and c share that slot, and b quietly changed. Append only allocates when the capacity is used up. Then it makes a bigger array, roughly doubling for small slices and growing by a smaller factor for large ones, copies the elements, and returns a slice on the new array. Old slices keep pointing at the old one. To stop this, I cap the slice with a full slice expression so the next append must copy, or I clone it first."
a := make([]int, 3, 10)
b := append(a, 4) // room left: writes into a's array
c := append(a, 5) // same slot: overwrites b[3]
fmt.Println(b[3], c[3]) // 5 5
// safe: force a copy on the next append
d := append(a[:len(a):len(a)], 6)
Assuming every append makes a new copy, or ignoring append's return value.
Rule: concurrent reads are fine; any write alongside another read or write needs synchronisation.
Failure: the runtime often detects it and stops the program with a fatal error that recover cannot catch.
Fixes: a Mutex or RWMutex around the map; sync.Map for keys written once and read many times, or goroutines touching separate keys.
"Maps aren't safe for concurrent use. Many goroutines reading at once is fine, but as soon as one writes while another reads or writes, it's a data race. Go's runtime checks for this cheaply and, when it catches it, crashes the whole program with a fatal error about concurrent map writes. That's not a normal panic, so recover can't save you. Even when it isn't caught, the map can be corrupted. The usual fix is a struct holding the map and a Mutex, or an RWMutex if reads dominate. sync.Map exists, but it's tuned for specific patterns: keys written once and read many times, like a cache that only grows, or goroutines working on separate keys. For general use it loses type safety and can be slower, so a plain map with a lock is my default. The race detector catches this in tests."
Believing maps are safe because each write is a single statement, or reaching for sync.Map by default.
Values: errors are returned as the last result and checked right away.
Wrap: fmt.Errorf with the w verb adds context and keeps the original error in the chain.
Is: checks the chain for a specific value, a sentinel like sql.ErrNoRows.
As: finds an error of a given type in the chain so you can read its fields.
"In Go an error is just a value, returned last and checked right after the call. When I pass one up, I add context about what I was doing, like load user 42, so the final log line reads as a story. I wrap with fmt.Errorf using the w verb, which keeps the original error inside. If I used the v verb instead, it would flatten it to text and callers could no longer inspect it. Higher up, errors.Is walks the chain looking for a specific value, a sentinel error like ErrNotFound, which is how I'd turn it into an HTTP 404. errors.As walks the chain looking for a type, and fills in a variable so I can read fields, like the path on a fs.PathError. I handle each error once: either log it or return it, not both."
var ErrNotFound = errors.New("not found")
func loadUser(id string) (*User, error) {
u, err := repo.Find(id)
if err != nil {
return nil, fmt.Errorf("load user %s: %w", id, err)
}
return u, nil
}
if errors.Is(err, ErrNotFound) { /* return 404 */ }
var pe *fs.PathError
if errors.As(err, &pe) { log.Println(pe.Path) }
Comparing error strings to decide what happened, or logging and returning the same error at every layer.
Defer: runs when the function returns, last in first out; arguments are evaluated when the defer line runs.
Panic: unwinds the stack running deferred calls; an unrecovered panic in any goroutine ends the program.
Recover: only works when called directly in a deferred function, in the same goroutine that panicked.
When: panic for impossible states and programmer bugs; return errors for expected failures.
"defer schedules a call to run when the surrounding function returns, whether it returns normally or by panicking. Several defers run last in, first out. The detail people miss is that the arguments are evaluated at the defer line, not at the end, so defer fmt.Println(i) prints the value i had then. A deferred closure, though, sees the latest values, and can even change named results. A panic stops normal flow and unwinds the stack, running the defers on the way. If nothing recovers it, the program crashes, and that's true for a panic in any goroutine, not just main. recover only stops a panic when called directly inside a deferred function in that same goroutine. I panic for bugs, like an impossible state, and return errors for everything that can reasonably go wrong, like bad input or a failed call."
func safeDivide(a, b int) (q int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
return a / b, nil // panics when b == 0
}
Using panic and recover as try and catch for ordinary errors, or thinking recover works from any function.
Agree on the goal: one bad request should not take the service down.
Right place: recover at boundaries: request middleware, each worker goroutine, with logs and a metric.
Why not everywhere: panics signal bugs; swallowing them hides corrupted state, and some fatal errors cannot be recovered anyway.
"I'd agree with the goal. One bad request shouldn't kill the process for everyone. But recover in every function is the wrong tool. A panic means something is broken, like a nil pointer or an index out of range, and if every function swallows it, we carry on with half-updated state and the bug stays hidden. So I'd suggest recovery at the boundaries. The standard HTTP server already recovers a panic in a handler and logs it, but it doesn't cover goroutines the handler starts, so every goroutine we launch for background work needs its own deferred recover that logs the stack and bumps a metric. Then panics are visible and we fix them. I'd also mention some failures, like concurrent map writes, are fatal errors that recover can't catch at all, so the real fix is still the bug."
Agreeing to blanket recovery, or not knowing that a panic in any goroutine ends the whole program.
Table: a slice of cases with name, input and expected output, looped with t.Run so each case is a named subtest.
Failures: t.Errorf with the input, what you got and what you wanted, so a failure explains itself.
Benchmark: a BenchmarkX function looping b.N times, run with go test -bench and -benchmem.
"The Go way is a table-driven test. I declare a slice of anonymous structs, each with a name, the input, the expected value and whether I expect an error. Then I loop and call t.Run with the case name, so each one shows as its own subtest, I can run a single one with the run flag, and adding a case is one line. My failure message shows the input and what I got, so nobody has to open the code to understand it. For a benchmark, I write a function starting with Benchmark that takes a testing.B and calls the code b.N times. The framework raises N until the timing is stable, and newer Go versions offer b.Loop, which is harder to get wrong. I run it with go test -bench and add -benchmem to see allocations per operation, which is usually where the real win is."
func TestParsePort(t *testing.T) {
tests := []struct {
name, in string
want int
wantErr bool
}{
{"valid", "8080", 8080, false},
{"empty", "", 0, true},
{"too big", "70000", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParsePort(tt.in)
if (err != nil) != tt.wantErr || got != tt.want {
t.Errorf("ParsePort(%q) = %d, %v; want %d", tt.in, got, err, tt.want)
}
})
}
}
func BenchmarkParsePort(b *testing.B) {
for i := 0; i < b.N; i++ {
ParsePort("8080")
}
}
Pulling in a heavy framework for simple cases, or timing code with a manual stopwatch instead of a benchmark.
What: the -race flag instruments memory accesses and reports two goroutines touching the same data, at least one writing, with no synchronisation.
Where: go test -race in CI; builds with it are slower and use more memory, so not usually in production.
Limits: it only sees races that happen during that run, and race-free code can still have logic bugs.
"The race detector is built into the toolchain. I add the race flag to go test, go run or go build, and the compiler instruments every memory access. At run time it watches for two goroutines touching the same memory where at least one is writing and nothing, like a lock or a channel, orders them. When it finds one it prints both stack traces, the write and the other access, which usually points straight at the bug. The reports are real races, so I treat every one as a bug. The big limit is that it only sees what actually happens in that run. If a test never exercises the concurrent path, it reports nothing. So I run tests with race in CI, and I write tests that really hit code from several goroutines. It also slows things down a lot, so it's not something I leave on in production."
Believing a clean race-detector run proves the code has no races.
Escape analysis: the compiler puts a value on the heap if it may outlive its function; -gcflags=-m shows the decisions.
Collector: concurrent mark and sweep, non-moving and not generational, with short stop-the-world pauses.
Pacing: GOGC sets how far the heap grows before the next cycle; GOMEMLIMIT sets a soft memory limit.
Cutting cost: fewer allocations: preallocate, reuse buffers, sync.Pool for hot temporary objects.
"Go doesn't let me choose stack or heap. The compiler runs escape analysis: if it can prove a value doesn't outlive the function, it stays on the stack, which is basically free. If I return a pointer to it, store it somewhere long-lived, or a goroutine closure captures it, it escapes to the heap. I can see those decisions with the m flag in gcflags. The collector is a concurrent mark and sweep. It runs mostly alongside my code, with short stop-the-world pauses and a write barrier while marking. It doesn't move objects and isn't generational. GOGC controls pacing: with the default, the heap can roughly double over the live data before the next cycle. GOMEMLIMIT adds a soft cap, useful in containers. In practice GC cost tracks allocation rate, so I profile allocations and cut them where they're hot."
Saying new always allocates on the heap and locals always live on the stack, or claiming Go has no GC pauses at all.
Symptom: what stopped, how it showed, why it was hard to reproduce.
Evidence: a goroutine dump from pprof or a quit signal, grouped by where goroutines were stuck.
Fix and follow-through: the real cause, the change and what stops it happening again.
"At my last company an internal queue consumer would sometimes stop processing, with no errors, and a restart fixed it. Next time it hung I pulled a goroutine dump from the pprof endpoint instead of restarting. Most goroutines were waiting on the same Mutex, and one worker held that lock while it was blocked sending on an unbuffered channel. The goroutine that read from that channel needed the same lock before it could receive, so each was waiting on the other. It only happened when a batch finished at the same moment a config reload ran, which is why it was rare. The fix was to copy what we needed under the lock, release it, and then send. I added a test that ran both paths together in a loop, and a team rule: never block on a channel while holding a lock."
A story where the fix was a timeout or a scheduled restart, with the root cause never found.
Problem: the metric that was bad and why it mattered.
Measure: pprof CPU and heap or allocation profiles, and what stood out.
Change and proof: the fix, the benchmark or metric before and after.
"In a previous role we had an API whose slowest requests were getting worse as traffic grew, and CPU was high for how little it did. I took a CPU profile and an allocation profile from the pprof endpoint under real load. Two things stood out. A regular expression was being compiled inside the request handler on every call, and we were building responses by appending to slices that started empty and grew many times. I moved the regex compile to package level so it happened once, and preallocated the slices since we knew the size. I wrote benchmarks for the handler first, so I could show allocations per request dropping sharply, not just say it felt faster. After release, tail latency came down by about half and the service needed fewer instances. I kept the benchmarks in the repo so a regression would show up."
Optimising by guesswork, with no profile before and no measurement after.
Project: what it did and your part in it.
Why Go: concrete strengths you used: goroutines, a single static binary, fast builds, the standard library.
Trade-offs: what was harder, and how you worked with it.
"In my final-year project I built a service that checked the health of a few hundred websites every minute and alerted when one went down. Go fit well. Each check was a goroutine with a context timeout, so hundreds of checks ran at once without any thread pool tuning. The standard library had the HTTP client, JSON and testing I needed, so there were very few dependencies. And deploying was just copying one static binary into a small container. What I missed at first was the error handling: writing if err != nil so often felt noisy until I started adding real context to each error, and then the logs got much more useful. I also missed some of the collection helpers other languages have, though generics and the slices package have covered most of that since."
Only saying Go is fast and simple, with no concrete example of a feature you used or a trade-off you felt.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.