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.
package main
import "fmt"
type Config struct {
Port int
Debug bool
Name string
Tags []string
Options map[string]string
}
func main() {
var c Config
fmt.Printf("port=%d debug=%v name=%q tags==nil:%v options==nil:%v\n",
c.Port, c.Debug, c.Name, c.Tags == nil, c.Options == nil)
}
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.
package main
import "fmt"
type UserID struct {
Name string
Age int
}
type UserProfile struct {
Name string
Tags []string
}
func main() {
a := UserID{"Ann", 30}
b := UserID{"Ann", 30}
fmt.Println(a == b)
x := UserProfile{Name: "Ann", Tags: []string{"go"}}
y := UserProfile{Name: "Ann", Tags: []string{"go"}}
_, _ = x, y
// fmt.Println(x == y) // compile error: struct containing []string cannot be compared
}
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.
package main
import (
"bytes"
"fmt"
"strings"
)
func main() {
var sb strings.Builder
sb.WriteString("hello")
sb.WriteByte(' ')
sb.WriteString("world")
fmt.Println(sb.String())
var buf bytes.Buffer
buf.Write([]byte{0x48, 0x69})
buf.WriteByte('!')
fmt.Println(buf.String())
s := "cat"
b := []byte(s) // normally copies
b[0] = 'b'
fmt.Println(s, string(b))
}
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.
package main
import "fmt"
type MyErr struct{}
func (*MyErr) Error() string { return "my error" }
func returnsTypedNil() error {
var e *MyErr = nil
return e
}
func main() {
var p *int = nil
var s []int = nil
var m map[string]int = nil
var err error = returnsTypedNil()
fmt.Println(p == nil)
fmt.Println(len(s), cap(s), s == nil)
fmt.Println(m["missing"])
fmt.Println(err == 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.
package main
import "fmt"
type Point struct{ X, Y int } // comparable
type Bag struct{ Items []string } // not comparable because of slice field
func Contains[T comparable](xs []T, target T) bool {
for _, x := range xs {
if x == target {
return true
}
}
return false
}
func main() {
p1, p2 := Point{1, 2}, Point{1, 2}
fmt.Println(p1 == p2)
m := map[Point]string{p1: "value"}
fmt.Println(m[p2])
fmt.Println(Contains([]string{"a", "b"}, "b"))
var a any = []int{1}
var b any = []int{1}
_, _ = a, b
// fmt.Println(a == b) // panic: comparing uncomparable type []int
}
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.
package main
import "fmt"
func main() {
s := "é🙂"
fmt.Println(len(s)) // bytes: é is 2 bytes, 🙂 is 4 bytes
fmt.Println(len([]rune(s))) // Unicode code points
fmt.Printf("first byte: %x\n", s[0])
for i, r := range s {
fmt.Printf("byte index %d: %q U+%04X\n", i, r, r)
}
}
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.
package main
import "fmt"
func main() {
a := [3]int{1, 2, 3}
b := a
b[0] = 99
fmt.Println(a, b)
s := []int{1, 2, 3}
t := s
t[0] = 99
fmt.Println(s, t)
fmt.Println(len(s), cap(s))
}
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.
package main
import "fmt"
func main() {
counts := map[string]int{"a": 0, "b": 2}
fmt.Println(counts["missing"]) // zero value for int
v, ok := counts["a"]
fmt.Println(v, ok) // present even though value is zero
var m map[string]int
fmt.Println(m["x"]) // read from nil map is OK
// m["x"] = 1 // panic: assignment to entry in nil map
m = make(map[string]int)
m["x"] = 1
for k, v := range counts {
fmt.Println(k, v) // order is not guaranteed
}
}
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.
package main
import "fmt"
func main() {
base := []int{1, 2, 3, 4}
a := base[:2] // len 2, cap 4
b := base[2:] // len 2, cap 2
a[0] = 99
fmt.Println(base, a, b)
a = append(a, 77) // reuses base's backing array, overwrites base[2]
fmt.Println(base, a, b)
c := append([]int(nil), base[:2]...) // defensive copy
c[0] = 42
fmt.Println(base, c)
}
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.
package main
func collectNoPrealloc(input []int) []int {
var out []int
for _, v := range input {
out = append(out, v*2)
}
return out
}
func collectPrealloc(input []int) []int {
out := make([]int, 0, len(input))
for _, v := range input {
out = append(out, v*2)
}
return out
}
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.
package main
import "fmt"
func lookup() (string, bool) {
return "gopher", true
}
func main() {
name, _ := lookup() // ignore the bool
fmt.Println(name)
for i, _ := range []int{10, 20} {
fmt.Println(i)
}
}
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.
// a/a.go
package a
import "fmt"
var V = func() int {
fmt.Println("a var")
return 1
}()
func init() { fmt.Println("a init") }
// main.go
package main
import (
"fmt"
"example/a"
)
var M = func() int {
fmt.Println("main var", a.V)
return 2
}()
func init() { fmt.Println("main init") }
func main() { fmt.Println("main") }
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.
// module example.com/app
// internal/store/store.go
package store
type Client struct { // exported type
dsn string // unexported field
}
func New(dsn string) *Client { return &Client{dsn: dsn} } // exported
func parseDSN(s string) string { return s } // unexported
// cmd/api/main.go -- allowed: inside example.com/app tree
package main
import "example.com/app/internal/store"
func main() {
c := store.New("db")
_ = c
// c.dsn is not accessible here: field is unexported.
}
// Code outside example.com/app cannot import example.com/app/internal/store.
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.
package main
import "fmt"
func f() (result int) {
x := 1
defer fmt.Println("arg evaluated now:", x)
defer func() {
result++
fmt.Println("deferred closure sees x later:", x)
}()
x = 2
return 10
}
func main() {
fmt.Println("return:", f())
}
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 { ... }`.
package main
import (
"errors"
"fmt"
)
func findUser(id int) (string, error) {
if id <= 0 {
return "", errors.New("invalid user id")
}
if id == 42 {
return "", fmt.Errorf("user %d not found", id)
}
return "alice", nil
}
func handler(id int) error {
name, err := findUser(id)
if err != nil {
return fmt.Errorf("find user: %w", err)
}
fmt.Println(name)
return 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.
var in <-chan int = source
for in != nil {
select {
case v, ok := <-in:
if !ok {
in = nil // disables this receive case
continue
}
fmt.Println(v)
case <-ctx.Done():
return
}
}
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.
package main
import (
"sync"
"sync/atomic"
)
var requests atomic.Int64
func recordRequest() {
requests.Add(1) // one independent counter update
}
type Account struct {
mu sync.Mutex
balance int
limit int
}
func (a *Account) Withdraw(n int) bool {
a.mu.Lock()
defer a.mu.Unlock()
// The check and update must be one protected invariant.
if a.balance-n < -a.limit {
return false
}
a.balance -= n
return true
}
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.
package worker
import (
"context"
"sync"
)
type Job int
type Result int
func StartWorkers(ctx context.Context, jobs <-chan Job, n int) <-chan Result {
results := make(chan Result)
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return
}
r := Result(j * 2)
select {
case results <- r:
case <-ctx.Done():
return
}
}
}
}()
}
go func() {
wg.Wait()
close(results) // close after all senders are done
}()
return results
}
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.
func handler(w http.ResponseWriter, r *http.Request) {
go func() {
result := <-slowCh // may block forever
_ = result
}()
}