Go
Go Backend Interview Questions: What Strong Answers Cover
Go interview topics and questions covering strings, slices, maps, interfaces, goroutines, channels, context, synchronization, and backend reliability.
Go backend interviews often begin with compact language examples and then move toward production behavior: cancellation, concurrency limits, data races, database pools, HTTP timeouts, and graceful shutdown.
Start with the topic map below, then use the worked questions to practice predicting behavior, explaining the mechanism, and connecting it to a reliable service design.
Main Go interview topics to prepare
- Strings: immutability, UTF-8, bytes versus runes, and slicing at byte boundaries. A string can contain arbitrary bytes; it is not guaranteed to be valid UTF-8. Understand conversions between
stringand[]byte, including copies, allocations, and possible compiler optimizations. See Go's strings guide. - Slices:
lenversuscap, underlying arrays,append, and reallocation. Explain how subslices share storage and why a small subslice can retain a large array. Slice assignment copies the descriptor;copycopies elements. Know when to allocate independent storage and how nil and empty slices differ. - Maps: hashing, lookup, growth, unspecified iteration order, and synchronization when reads and writes can overlap. For internals, establish the Go version: Go 1.24 introduced Swiss Table-based maps. Buckets, overflow buckets, and evacuation during growth belong to the older implementation; modern maps use groups and tables.
- Interfaces: distinguish a nil interface from an interface containing a typed nil. Explain the dynamic type and value, especially when returning an
error. - Concurrency and runtime: goroutines, scheduling, work stealing, blocking system calls, and goroutine stack growth. Know the G/M/P model: a goroutine, an OS thread, and the runtime resources needed to execute Go code. The runtime guide describes these roles.
- Channels: buffered versus unbuffered operation and when sends or receives block. Closing preserves buffered values for receivers; once drained, receives return the zero value with
ok == false. Sending to a closed channel panics. Sending or receiving on a nil channel blocks indefinitely. select: when several cases are ready, one is chosen pseudo-randomly.defaultruns only when none can proceed; nil-channel cases cannot proceed. Practice cancellation patterns and explain why a ready cancellation case has no priority over another ready case. See the language specification.- Context: cancellation propagation, deadlines, calling cancel functions, and passing context into blocking operations. Cancellation is cooperative: every goroutine needs a way to stop so abandoned work does not leak.
- Mutexes and atomics: choose mutexes for shared invariants and atomics for suitable individual operations. Explain data races and compound read-modify-write races. Making separate fields atomic does not automatically make a multi-field operation atomic.
For backend roles, connect these fundamentals to database transactions, HTTP timeouts, and graceful shutdown. The questions below work through a selection of these topics.
1. What does defer execute, and in what order?
Consider this function:
func show() {
defer fmt.Print("A")
defer fmt.Print("B")
fmt.Print("C")
}It prints CBA. Deferred calls run when the surrounding function returns, in last-in, first-out order. Their function value and arguments are evaluated when the defer statement executes, not later when the call runs.
A strong answer goes beyond predicting the output. It explains why defer is useful for cleanup near resource acquisition, how it behaves during a panic, and why putting a defer inside a long-running loop can retain resources until the entire function returns.
2. When does append change another slice?
A slice contains a pointer to an underlying array together with a length and capacity. Two slices can therefore view the same storage.
base := []int{1, 2, 3, 4}
a := base[:2]
b := append(a, 9)
fmt.Println(base, b)
c := append(a, 8, 7, 6)
fmt.Println(base, c)The first append fits within a's capacity, so it reuses the existing array and changes base to [1 2 9 4]. The second append needs five elements in total, exceeds that capacity, and allocates new storage. It prints [1 2 9 4] [1 2 8 7 6].
Strong answers connect this behavior to API design. If a function must not mutate a caller's storage, it should make that ownership rule clear and copy when necessary. Code should also use the slice returned by append, because the backing array may change.
3. Why can an interface containing a nil pointer be non-nil?
An interface value contains both a dynamic type and a dynamic value. It compares equal to nil only when both are absent.
type LookupError struct{}
func (*LookupError) Error() string { return "not found" }
func find() error {
var err *LookupError
return err
}
fmt.Println(find() == nil) // falseThe returned interface has the dynamic type *LookupError, even though its dynamic value is a nil pointer. That makes the interface itself non-nil.
The production lesson is not to return a typed nil as an error. Return a literal nil on success and a real error value on failure. A complete answer may also mention that calling methods on a typed nil can panic if the method dereferences its receiver.
4. How should context flow through an HTTP request?
Request-scoped work should receive the request context and pass it through to database calls, outbound HTTP requests, and other operations that support cancellation.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
user, err := h.repo.FindUser(ctx, userID)
// Handle the result.
}The component that creates a timeout owns the corresponding cancel function. Calling it releases resources even when the work finishes before the deadline.
A strong answer also separates concerns: pass context.Context explicitly, usually as the first parameter; do not store it in a long-lived struct; and use context values only for request-scoped data that crosses API boundaries. Cancellation is cooperative, so every blocking stage must observe the context for it to have an effect.
5. Is this program guaranteed to print 42?
var value int
var ready bool
go func() {
value = 42
ready = true
}()
for !ready {
}
fmt.Println(value)No. The reads and writes occur concurrently without synchronization, creating data races on both variables. Observing ready does not establish that the reader must observe the write to value, and the loop is not guaranteed to finish.
One repair is to establish a happens-before relationship with a channel:
done := make(chan struct{})
go func() {
value = 42
close(done)
}()
<-done
fmt.Println(value)Depending on the problem, a mutex, atomic operation, or another synchronization primitive may be more appropriate. Strong answers choose the mechanism that expresses ownership clearly and mention running relevant tests with the race detector rather than assuming tests alone will expose every race.
6. When should a channel be buffered?
An unbuffered send can complete only when a receiver is ready to receive the value. A buffered channel lets sends complete while space remains, allowing the producer and consumer to run at different speeds for a limited time.
Neither form is universally better. An unbuffered channel provides a direct synchronization point. A buffer can absorb a known burst or represent a deliberate concurrency limit, but an arbitrary large buffer often postpones overload instead of solving it. Its capacity should follow from the flow-control requirement.
Channel ownership matters too. The goroutine responsible for producing the stream should normally close the channel when no more values will arrive. Receivers use the second receive result or range to detect closure. Sending to or closing an already closed channel panics; receiving from a closed and drained channel returns the zero value immediately.
Cancellation prevents a producer from leaking when a downstream consumer stops early:
func produce(ctx context.Context, out chan<- Job, jobs []Job) error {
defer close(out)
for _, job := range jobs {
select {
case out <- job:
case <-ctx.Done():
return ctx.Err()
}
}
return nil
}A strong answer explains who creates and closes the channel, whether every send and receive can terminate, and what happens when one stage fails. Channels communicate values and synchronization; they do not provide lifecycle management or error propagation automatically.
7. What can go wrong with one goroutine per item?
Starting a goroutine is inexpensive, but an unbounded number is not free. A burst can exhaust memory, overwhelm a database pool or upstream service, and leave goroutines blocked indefinitely on sends, receives, locks, or I/O.
A production design normally includes:
- a concurrency limit derived from downstream capacity;
- cancellation and timeouts;
- bounded input queues and a deliberate overload policy;
- clear ownership of channel closure;
- a way to collect errors and wait for every started goroutine.
A semaphore, fixed worker pool, or concurrency-limited task group can all work. The important interview signal is explaining what resource the limit protects and how blocked goroutines terminate when the request is cancelled.
8. What is sql.DB, and how should transactions use it?
Despite its name, sql.DB is not one database connection. It is a concurrency-safe handle that manages a pool of underlying connections. Applications usually create and reuse one handle, verify connectivity when readiness requires it, and configure limits such as maximum open connections, idle connections, and connection lifetime for the database and deployment environment.
A transaction reserves a connection for its lifetime. All operations that belong to the transaction must use the *sql.Tx, not the original *sql.DB handle.
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Use tx.QueryContext, tx.ExecContext, and other tx methods here.
return tx.Commit()The deferred rollback is harmless after a successful commit and protects early returns. A strong answer also discusses isolation, lock duration, retryable failures, context cancellation, and why slow work should not keep a transaction—and therefore a pooled connection—open unnecessarily.
9. How do you shut down a Go HTTP service gracefully?
Graceful shutdown is more than catching a signal. The service should stop accepting new work, allow active requests a bounded time to finish, cancel background activity, and close resources in a defined order.
For an http.Server, Shutdown closes listeners and idle connections, then waits for active connections to become idle until the supplied context expires. It does not automatically wait for hijacked connections such as WebSockets, so those need explicit coordination.
A complete answer covers the process around the API:
- Mark the instance unready so traffic stops arriving.
- Receive a termination signal and create a shutdown deadline.
- Call
Server.Shutdownand wait for the serving goroutine to exit. - Stop workers and flush or close dependencies without accepting new work.
- Force termination only after the deadline and report unfinished cleanup.
The ordering depends on the service. The useful interview discussion is which work may be abandoned, which must be drained, and how deployment infrastructure gives the process enough time to complete that sequence.
Turn questions into interview practice
Give each question an initial answer in two minutes. Then add a constraint: a client disconnects, an upstream stalls, traffic spikes, or the process receives a termination signal. Follow-ups reveal whether the first answer describes a mechanism you understand or a rule you memorized.
You can also take the Go backend mock interview to practice these topics in a timed round and review feedback by competency.