Selected Go interview questions for backend developers, grouped by topic and rendered from the same question catalog that powers EngineerSpeak practice.
1Explain how Go's zero values work for built-in and reference-like types, and why they matter when declaring variables without explicit initialization.
In Go, a variable declared without an explicit initializer is automatically initialized to the zero value of its type. Numeric types become 0, bool becomes false, string becomes "", and arrays or structs are zeroed element by element or field by field. Pointer-like or reference-like types such as pointers, slices, maps, channels, functions, and interfaces have nil as their zero value. This matters because Go variables and omitted struct fields start in a deterministic state rather than containing garbage, and many APIs are designed so the zero value is a useful default, though some nil values still need initialization before certain operations.
2How does Go handle equality for structs and what happens when a struct contains incomparable fields?
Struct values in Go can be compared with `==` and `!=` only when every field in the struct is comparable. Equality compares corresponding fields using each field's own equality rule. If a struct contains an incomparable field such as a slice, map, or function, the struct type is not comparable, and comparing two values of that struct type with `==` is a compile-time error. For such structs, use custom comparison logic or an appropriate deep-equality helper, especially in tests.
3Explain string immutability in Go and the relationship between string, []byte, bytes.Buffer, and strings.Builder.
A Go `string` is an immutable sequence of bytes, often UTF-8 text but not required to be valid UTF-8. You cannot modify a string in place; to change contents you typically convert to `[]byte` for byte-level edits or `[]rune` for code-point edits, then convert back. Normal conversions between `string` and `[]byte` copy data and may allocate, so repeated conversions or repeated concatenation in loops can be expensive. `strings.Builder` is optimized for efficiently building strings, while `bytes.Buffer` is a mutable byte buffer useful for byte-oriented data and I/O and can also produce a string.
4Explain how nil works differently for pointers, slices, maps, channels, functions, and interfaces in Go.
In Go, nil is the zero value for pointers, slices, maps, channels, functions, and interfaces, but operations on those nil values differ by type. A nil pointer can be compared to nil, but dereferencing it panics. A nil slice has length and capacity 0 and can be ranged over and appended to. A nil map can be read from and ranged over, but assigning to it panics. Sending to or receiving from a nil channel blocks forever, and closing a nil channel panics. Calling a nil function panics. An interface is nil only when it has no dynamic type and no dynamic value; an interface holding a typed nil value, such as a nil pointer, is not itself nil.
5What are comparable types in Go, and how do comparability rules affect map keys, equality, and generics constraints?
Comparable types in Go are types whose values can be compared with `==` and `!=`. Basic types, pointers, channels, interfaces, and structs/arrays whose fields or elements are comparable are comparable; slices, maps, and functions are not comparable except to `nil`. Map keys must be comparable. Equality follows the type's comparison rules, and interface comparison depends on the dynamic concrete values; if a compared interface contains an incomparable dynamic value, the comparison panics. In generics, the predeclared `comparable` constraint allows type parameters to be compared with `==`/`!=` and used as map keys.
6How does Go represent bytes, runes, and UTF-8 encoded text, and why can len(s) differ from the number of user-visible characters?
In Go, `byte` is an alias for `uint8` and represents one raw byte, while `rune` is an alias for `int32` and represents a Unicode code point. A `string` is a read-only sequence of bytes, commonly UTF-8 encoded text but able to contain arbitrary bytes. `len(s)` returns the number of bytes, not runes or user-visible characters. Indexing a string returns a byte; ranging over a string decodes UTF-8 and yields byte indexes plus runes. `len(s)` can differ from the number of visible characters because UTF-8 may use multiple bytes per code point and because one user-visible character may be composed of multiple code points, such as combining marks or emoji sequences.
7Describe the difference between arrays and slices in Go, including how length, capacity, and underlying storage behave.
An array in Go has a fixed length that is part of its type, such as [3]int; it stores its elements directly, and assigning or passing an array copies the whole array value. A slice, such as []int, is a small descriptor over an underlying array: conceptually it contains a pointer to elements, a length, and a capacity. A slice’s length is the number of visible elements; its capacity is how many elements can be used from the slice start before reaching the end of the backing array. Slices are flexible: reslicing changes the descriptor, and append may reuse the same underlying array if capacity allows or allocate a new one if not.
8How does Go's map type behave regarding key types, missing keys, nil maps, and iteration order?
Go map key types must be comparable; slices, maps, and functions cannot be used directly as keys. Looking up a key that is not present returns the element type's zero value, so the comma-ok form (`v, ok := m[k]`) is used to distinguish absence from a present zero value. A nil map can be read from and ranged over, but assigning to it panics; initialize it before writing. Map iteration order is unspecified and code must not depend on it.
9Describe how slice reslicing and assignment can cause multiple slices to share the same underlying array, and what bugs this can create.
A slice value is a header pointing into an underlying array. Assigning a slice or passing it to a function copies only that header, not the elements. Reslicing creates another header pointing to a range of the same backing array. Therefore multiple slices can alias the same storage: changing an element through one slice can be visible through another, and appending to one slice can overwrite data visible to another if it still has spare capacity. Bugs include surprising mutations, corrupted results, retaining large backing arrays through small subslices, and data races when aliases are used concurrently. To avoid unintended sharing, make a defensive copy with copy or append([]T(nil), s...), or limit capacity with a full-slice expression before append.
10Explain slice growth during append at a conceptual level and the performance implications of repeated reallocation.
When append adds elements to a slice, it writes into the existing backing array if the slice has enough capacity. If capacity is insufficient, Go allocates a larger backing array, copies the existing elements, writes the new elements, and returns a slice header pointing to the new storage. The exact growth policy is implementation-dependent, but conceptually capacity grows enough to make repeated append amortized efficient. Repeated reallocations still cost CPU for copying, create allocations, increase GC pressure, and may break sharing with old slice aliases. If you know the expected size, preallocate with make([]T, 0, n) when building by append or make([]T, n) when filling by index to reduce reallocations.
11How does the blank identifier work in Go for unused values, imports, and compile-time interface checks?
The blank identifier `_` is a write-only placeholder. Assigning to it discards the value and does not create a usable variable. It is used to ignore unneeded return values or loop variables, to import a package only for side effects with `import _ "pkg"`, and to make compile-time interface implementation checks such as `var _ io.Reader = (*MyReader)(nil)`. A blank import still runs the imported package's initialization. An interface-check assignment fails to compile if the concrete type's method set does not satisfy the interface.
12How does package initialization order work in Go, including init functions and imported dependencies?
Go initializes packages in dependency order. A package's imported dependencies are initialized before the importing package. Within a package, package-level variables are initialized before any `init` functions, with variable initialization ordered by dependency and declaration order as defined by the language. Then the package's `init` functions run automatically; a package may have multiple `init` functions, and they cannot be called directly. Each package is initialized once. For an executable, the import graph is initialized first, then package `main` is initialized, and finally `main.main` is called.
13Explain package visibility rules in Go, including exported identifiers and the internal/ directory convention.
In Go, package visibility is controlled by identifier naming, not access keywords. An identifier whose name starts with an uppercase Unicode letter is exported and can be referenced from other packages; other identifiers are unexported and usable only from within the same package. This applies to functions, types, methods, variables, constants, and struct fields. Packages use exported identifiers to define their public API and keep implementation details unexported. Separately, a package located under an `internal/` directory may only be imported by code whose import path is within the parent tree of that `internal` directory; this is enforced by the Go toolchain.
14How does defer work in Go, including execution order, argument evaluation time, and interaction with return values?
`defer` schedules a function call to run when the surrounding function is exiting, whether it exits by a normal return or by panic unwinding. Multiple deferred calls run in last-in, first-out order. The deferred function value and its arguments are evaluated immediately when the `defer` statement executes, but the call itself runs later. With named return values, a return statement assigns the return values first, then deferred functions run, so a deferred closure can observe or modify named result variables before the caller receives them. This makes `defer` useful for cleanup such as closing files, unlocking mutexes, and releasing resources.
15Explain Go's error handling model and the conventional ways errors are created, returned, and checked.
Go treats errors as ordinary values, not exceptions. The built-in `error` interface is satisfied by any type with an `Error() string` method. Functions conventionally return an `error` as the last result, where `nil` means success and a non-nil error means the caller must handle or propagate the failure. Simple errors are commonly created with `errors.New`, formatted errors with `fmt.Errorf`, and callers usually check `if err != nil { ... }`.
16How should panic recovery be handled in Go backend services, including what happens when a goroutine panics and when a process should recover versus crash?
A panic unwinds the current goroutine, running its deferred functions. `recover` only works when called from a deferred function in that same goroutine; one goroutine cannot recover another goroutine's panic. If a panic is not recovered, the process crashes. In backend services, recovery should usually be placed at isolation boundaries such as request handlers, RPC middleware, or worker goroutine entrypoints so one failing request or job does not take down the whole service. But if a panic may have corrupted shared state or made process integrity untrustworthy, it is safer to let the process crash and restart rather than recover and continue blindly.
17What are nil channels in Go, and how can they accidentally break code or intentionally disable select cases?
A nil channel is a channel variable whose value is nil, often because it was not initialized with make or was explicitly set to nil. Sending to or receiving from a nil channel blocks forever. In a select, a case involving a nil channel is never ready, so assigning a channel variable to nil can intentionally disable that case. Accidentally using a nil channel can make goroutines hang or make select logic stop handling expected events.
18How do atomic operations in sync/atomic differ from mutex-based synchronization, and when are they appropriate?
sync/atomic provides indivisible operations on individual memory locations, such as load, store, add, swap, and compare-and-swap, with synchronization/memory-ordering guarantees. A mutex protects a critical section, so it can guard arbitrary code and invariants involving multiple reads, writes, or fields. Atomics are appropriate for simple independent state such as counters, flags, sequence numbers, or carefully designed lock-free structures. Prefer a mutex when operations are compound, multiple values must stay consistent, or the atomic version would be hard to reason about or prove correct.
19How should channel ownership and goroutine lifecycle be designed to avoid goroutine leaks?
Design goroutines with an explicit owner, a clear shutdown signal, and a guaranteed exit path. The producer side generally owns closing a channel, especially an output channel; receivers should not close a channel while senders may still be active. Every blocking send, receive, loop, timer, or external call should either be guaranteed to complete or be able to unblock on cancellation, commonly through context.Context or a done channel. Use WaitGroup, errgroup, or similar coordination so workers are waited for and channels are closed only after senders exit.
20What are common causes of goroutine leaks in Go services, and how do you detect and fix them in production?
Common goroutine leaks in Go services come from goroutines blocked forever on channel sends or receives, waiting on other blocking operations without cancellation, stuck I/O without deadlines, background loops or tickers that never stop, and request-scoped goroutines that outlive the request. In production, you look for sustained growth in goroutine count and related symptoms, then inspect goroutine dumps or pprof goroutine profiles to see where goroutines are stuck. Fixing the leak means changing the code so those goroutines can exit: add cancellation and deadlines, stop tickers, close channels correctly, avoid detached request goroutines, and bound concurrency where needed.