Go Backend Developer Top 100 Interview Questions
100 Go backend developer interview questions with structured answers, comparison tables, flow diagrams, worked calculations and runnable code. Covers the language core, goroutines and channels, context, the standard HTTP and database packages, testing, profiling, performance and system design, from the basics to production incidents.
Questions 1-20 of 100
Q1.What does a Go backend developer own beyond writing HTTP handlers?
A Go backend developer owns services end to end: their design, correctness, performance and behavior in production. Handlers are a small part; most of the work is in how the service handles concurrency, data, failure and operations.
Typical responsibilities:
- API design: clear contracts, validation, versioning and consistent error responses.
- Concurrency: goroutines, channels and shared state that are correct under load and never leak.
- Data access: database queries, transactions, connection pools and caching.
- Reliability: timeouts, cancellation through
context, retries and graceful shutdown. - Performance: profiling, reducing allocations and sizing the service for its traffic.
- Testing: unit, integration and race-detector runs in continuous integration.
- Observability: structured logs, metrics, traces and health checks.
- Delivery: small, secure binaries and images, built reproducibly.
Go rewards simple, explicit code, so a good Go developer also keeps the codebase small, readable and free of unnecessary abstraction.
Q2.Why is Go a popular choice for backend services?
Go combines fast, compiled programs with a simple language, built-in concurrency and a strong standard library, which suits network services that must be reliable and easy to operate.
Main strengths:
- Single static binaries: a service compiles to one executable with no runtime to install, which makes deployment and containers simple.
- Lightweight concurrency: goroutines and channels make it practical to handle many thousands of concurrent connections.
- Fast compilation: quick builds keep development and continuous integration fast.
- Standard library: HTTP servers and clients, JSON, cryptography, testing and profiling are built in.
- Predictable performance: compiled code with a low-latency garbage collector.
- Simplicity: a small language with one standard formatter, so codebases look consistent across teams.
Trade-offs:
- Fewer abstractions mean more explicit, sometimes repetitive code, such as error checks.
- Generics are deliberately limited compared with some languages.
- Garbage collection still needs attention for very latency-sensitive or memory-heavy services.
Q3.How do zero values work in Go, and why do they matter?
Every variable in Go starts with a zero value when it is declared without an explicit value:
0for numbers,""for strings,falsefor booleans andnilfor pointers, slices, maps, channels, functions and interfaces. Structs get the zero value of each field.Why they matter:
- No uninitialized memory: a declared variable is always in a defined state.
- Useful zero values: well-designed types work without a constructor, such as
sync.Mutex,bytes.Bufferandstrings.Builder. - Simpler code: appending to a nil slice and reading from a nil map both work.
Traps to know:
- Writing to a nil map panics; maps must be created with
makeor a literal before writes. - A nil pointer field causes a panic when dereferenced.
- You cannot tell an explicit
0from "not set"; use a pointer or a separate flag when the difference matters, such as in partial updates.
Designing your own types so their zero value is ready to use is considered idiomatic Go.
Example
A zero-value struct is safe to read and append to, but writing to its nil map panics.
package main import "fmt" type Order struct { ID int Status string Paid bool Items []string Meta map[string]string } func main() { var o Order fmt.Printf("%d %q %t %v %v\n", o.ID, o.Status, o.Paid, o.Items == nil, o.Meta == nil) o.Items = append(o.Items, "book") fmt.Println(len(o.Items)) fmt.Println(o.Meta["missing"] == "") defer func() { fmt.Println("recovered:", recover()) }() o.Meta["key"] = "value" } // Output: // 0 "" false true true // 1 // true // recovered: assignment to entry in nil mapQ4.How do slices work internally, and what are the common pitfalls with `append`?
A slice is a small descriptor with three parts: a pointer to an underlying array, a length and a capacity. Several slices can share the same array, so changes through one can appear in another.
How
appendbehaves:- If there is spare capacity,
appendwrites into the existing array and returns a slice with a longer length. - If capacity is exhausted, it allocates a larger array, copies the elements and returns a slice pointing to the new array.
- Always use the returned slice:
s = append(s, x).
Common pitfalls:
- Shared arrays: two
appendcalls on the same base slice with spare capacity overwrite each other's element. - Sub-slices keep the whole array alive: a small slice of a huge array prevents the array from being garbage collected; copy the data if you keep it long term.
- Modifying a sub-slice changes the original.
Fixes: limit capacity with a full slice expression,
s[low:high:max], so the nextappendmust copy, or usecopyorslices.Clonewhen independence matters. Preallocate withmake([]T, 0, n)when the final size is known.Example
Appending to a shared base slice overwrites data; limiting capacity forces a copy.
package main import "fmt" func main() { base := make([]int, 3, 10) a := append(base, 1) b := append(base, 2) fmt.Println(a[3], b[3]) // 2 2 c := append(base[:3:3], 1) d := append(base[:3:3], 2) fmt.Println(c[3], d[3]) // 1 2 s := []int{1, 2, 3, 4, 5} t := s[1:3] t[0] = 99 fmt.Println(s, len(t), cap(t)) // [1 99 3 4 5] 2 4 }- If there is spare capacity,
Q5.How do arrays, slices and maps differ in Go?
Arrays have a fixed size that is part of their type, slices are flexible views over arrays and maps are hash tables from keys to values. Most Go code uses slices and maps; arrays appear mainly as fixed-size values, such as hashes.
Array Slice Map Fixed length, part of the type: [4]intVariable length view over an array: []intUnordered key-value table: map[string]intCopied in full on assignment or when passed to a function Copying shares the underlying array Copying shares the same table Comparable with ==if its elements areNot comparable, except with nilNot comparable, except with nilBest for fixed-size data, such as a 32-byte hash Best for ordered collections of any size Best for lookups by key Practical notes:
- Pass large arrays by pointer, or use slices, to avoid copying.
- Maps are not safe for concurrent writes; protect them with a mutex or use
sync.Mapfor specific read-heavy cases. - Map iteration order is deliberately randomized, so never depend on it; sort the keys when order matters.
- Arrays can be map keys because they are comparable, which is useful for composite keys.
Q6.How do maps work in Go, and why is their iteration order random?
A Go map is a hash table: each key is hashed to find the slot that holds its value, giving fast lookups, inserts and deletes on average. The runtime grows the table as it fills, moving entries to new slots.
Key behaviors:
- Lookup with presence check:
v, ok := m[key]distinguishes a missing key from a zero value. - Deleting with
delete(m, key)is safe even if the key is absent. - Keys must be comparable: strings, numbers, pointers, arrays and structs of comparable fields; not slices, maps or functions.
- Not safe for concurrent use: concurrent writes, or a write during a read, can crash the program with a fatal error.
Why iteration order is random: the runtime deliberately starts iteration at a random position, so programs cannot accidentally depend on an order that would change between versions or as the map grows. Code that needs order sorts the keys first, for example with
slices.Sorted(maps.Keys(m)).Performance tips:
- Give
makea size hint when the number of entries is known, to avoid repeated growth. - Use struct keys instead of concatenated strings for composite keys.
- Remember that deleting entries does not shrink the table; rebuild very large maps that have shrunk a lot.
- Lookup with presence check:
Q7.What is the difference between a value receiver and a pointer receiver?
A method with a value receiver gets a copy of the value, so changes inside the method are lost. A method with a pointer receiver gets the address, so it can modify the original and avoids copying large structs.
Value receiver Pointer receiver func (c Counter) Get() intfunc (c *Counter) Inc()Works on a copy; cannot change the caller's value Can modify the caller's value Both Tand*Tsatisfy interfaces using itOnly *Tsatisfies interfaces using itBest for small, immutable values Best for mutation, large structs and types containing locks Guidelines:
- If any method needs a pointer receiver, use pointer receivers for all methods of that type, for consistency.
- Never copy types that contain a
sync.Mutex; pointer receivers prevent accidental copies. - Small value types, such as a
Pointor aMoneyamount, are fine with value receivers.
Go automatically takes the address when you call a pointer method on an addressable value, so
c.Inc()works on a variablec, but a value stored in a map is not addressable, so the same call fails to compile onm[k].Inc().Example
The value method changes only a copy, and only the pointer type satisfies the interface.
package main import "fmt" type Counter struct{ n int } func (c Counter) IncByValue() { c.n++ } func (c *Counter) IncByPointer() { c.n++ } type Incrementer interface{ IncByPointer() } func main() { c := Counter{} c.IncByValue() c.IncByPointer() fmt.Println(c.n) // 1 var inc Incrementer = &c inc.IncByPointer() fmt.Println(c.n) // 2 }Assigning
cinstead of&ctoincwould not compile, becauseCounteritself does not have the pointer method.Q8.How do interfaces work in Go, and what does implicit implementation mean?
An interface is a set of method signatures. Any type that has those methods satisfies the interface automatically; there is no
implementskeyword. This is implicit implementation, and it lets consumers define the small interfaces they need without changing the types that satisfy them.Idiomatic use:
- Define interfaces where they are used, in the consuming package, not next to the implementation.
- Keep them small: one or two methods, like
io.Readerandio.Writer, which compose into larger ones. - Accept interfaces, return concrete types: functions take the minimal behavior they need and return structs, so callers keep full access.
What an interface value holds: a pair of a dynamic type and a dynamic value. Calling a method uses the dynamic type to find the right implementation.
Benefits:
- Easy testing, since a fake only needs the few methods the consumer calls.
- Loose coupling between packages.
- Standard library interfaces, such as
io.Reader,fmt.Stringeranderror, let your types plug into existing code.
Caution: do not create interfaces with a single implementation "just in case"; add them when a second implementation or a test needs one.
Q9.Why can an interface holding a nil pointer be non-nil?
An interface value is nil only when both its dynamic type and its value are nil. If you store a typed nil pointer in an interface, the interface has a type, so it is not equal to
nil, even though the pointer inside is.Where it bites: a function returns a custom error type through the
errorinterface:- Declaring
var err *MyErrorand returning it, even when no error happened, produces a non-nilerror. - The caller's
if err != nilcheck succeeds, and the program treats success as failure, or calls methods on a nil pointer.
How to avoid it:
- Return the literal
nilfor the success case, never a typed nil variable. - Declare error variables with the interface type,
var err error, rather than a concrete pointer type. - Return concrete error types only through the
errorinterface at the point where an error actually occurs.
Linters catch many of these cases, but understanding the two-part interface value is what makes the bug obvious.
Example
The same "no error" path returns a non-nil error when it returns a typed nil pointer.
package main import "fmt" type NotFoundError struct{ ID string } func (e *NotFoundError) Error() string { return "not found: " + e.ID } func findBad(id string) error { var err *NotFoundError if id == "missing" { err = &NotFoundError{ID: id} } return err } func findGood(id string) error { if id == "missing" { return &NotFoundError{ID: id} } return nil } func main() { fmt.Println(findBad("42") == nil) // false fmt.Println(findGood("42") == nil) // true }- Declaring
Q10.How do struct embedding and composition replace inheritance in Go?
Go has no classes or inheritance. Instead, a struct can embed another type, and the embedded type's fields and methods are promoted, so they can be used as if they belonged to the outer struct. This is composition: the outer type has the inner one, rather than being a subtype of it.
How embedding behaves:
- Promoted methods let the outer type satisfy interfaces that the embedded type satisfies.
- The outer type can define a method with the same name, which takes priority; the embedded version stays reachable as
outer.Inner.Method(). - There is no polymorphism through embedding: a method of the embedded type, calling another method, always calls its own version, not the outer type's override.
Good uses:
- Reusing small behaviors, such as embedding a mutex or a logger.
- Wrapping a type to add or change one method, such as embedding an
http.ResponseWriterto record the status code. - Composing interfaces, such as
io.ReadWriterembeddingio.Readerandio.Writer.
Caution: embedding exposes all promoted methods publicly; use a named field instead when the inner type is an implementation detail.
Q11.How do errors work in Go, and how do you wrap and inspect them?
Errors are ordinary values returned as the last result of a function, implementing the
errorinterface with a singleError() stringmethod. Callers check them explicitly, which makes failure paths visible in the code.Adding context with wrapping:
fmt.Errorf("load user %d: %w", id, err)adds context while keeping the original error inside.- Each layer adds what it knows, producing messages such as "handle request: load user 7: not found".
Inspecting wrapped errors:
errors.Is(err, ErrNotFound)checks whether any error in the chain equals a sentinel value.errors.As(err, &target)finds the first error in the chain of a given type and assigns it, giving access to its fields.errors.Joincombines several errors, such as validation failures, into one.
Good practices:
- Handle each error once: either handle it or return it with context, but do not log and return it at every layer.
- Define sentinel errors for conditions callers need to detect, and custom types when they need details.
- Wrap with
%wonly when callers should be able to inspect the cause; use%vto hide implementation details.
Example
A sentinel error and a typed error are both found through the wrapping chain.
package main import ( "errors" "fmt" ) var ErrNotFound = errors.New("not found") type ValidationError struct{ Field string } func (e *ValidationError) Error() string { return "invalid " + e.Field } func loadUser(id int) error { if id == 0 { return fmt.Errorf("load user: %w", &ValidationError{Field: "id"}) } return fmt.Errorf("load user %d: %w", id, ErrNotFound) } func main() { err := loadUser(7) fmt.Println(err) // load user 7: not found fmt.Println(errors.Is(err, ErrNotFound)) // true var ve *ValidationError fmt.Println(errors.As(loadUser(0), &ve), ve.Field) // true id }Q12.When should you use `panic` and `recover` in Go?
Use
paniconly for programming errors and truly unrecoverable situations, such as an impossible state or a failed invariant at startup. Expected failures, such as invalid input, missing records or network errors, should be returned aserrorvalues.Reasonable uses of panic:
- Startup failures where continuing makes no sense, such as an invalid required configuration, often through functions named
Must...likeregexp.MustCompile. - Internal invariants that indicate a bug if broken.
How recover works:
recoverstops a panic only when called directly inside a deferred function in the panicking goroutine. It returns the panic value and lets the function return normally.Where recover belongs:
- At goroutine and request boundaries, such as HTTP middleware that turns a panic in one handler into a 500 response and a log entry, instead of crashing the whole server.
- Every goroutine you start needs its own recovery if a panic must not crash the process, since a panic in any goroutine ends the program.
Avoid: using panic and recover as a general exception mechanism for control flow; it hides failures and makes code hard to follow.
- Startup failures where continuing makes no sense, such as an invalid required configuration, often through functions named
Q13.How does `defer` work, and what are its common pitfalls?
A
deferstatement schedules a function call to run when the surrounding function returns, whether it returns normally or by panicking. Deferred calls run in last-in, first-out order, which makes them ideal for cleanup, such as closing files and unlocking mutexes.Rules to remember:
- Arguments are evaluated immediately, when the
deferstatement runs, not when the deferred call executes. - Deferred closures see later changes to the variables they capture.
- Named results can be modified by deferred functions, which is how some code adds context to returned errors.
Common pitfalls:
- Defer in a loop: deferring
file.Close()inside a loop over thousands of files keeps every file open until the function returns; move the loop body into its own function. - Ignoring errors from deferred calls: closing a written file can fail; check the error when it matters, for example through a named result.
- Deferring before checking errors:
defer f.Close()must come after confirming the open succeeded.
Example
Arguments are captured when
deferruns, calls execute in reverse order and a deferred closure can change a named result.package main import "fmt" func tripled(n int) (result int) { defer func() { result *= 3 }() return n } func main() { for i := 0; i < 3; i++ { defer fmt.Println("deferred", i) } x := 1 defer fmt.Println("x at defer time:", x) x = 2 fmt.Println("x now:", x) fmt.Println("result:", tripled(5)) } // Output: // x now: 2 // result: 15 // x at defer time: 1 // deferred 2 // deferred 1 // deferred 0- Arguments are evaluated immediately, when the
Q14.How do generics work in Go, and when should you use them?
Generics let functions and types take type parameters, constrained by interfaces that describe which types are allowed. They remove duplicated code for algorithms and data structures that work the same way for many types.
Key pieces:
- Type parameters:
func Map[T, U any](xs []T, f func(T) U) []U. - Constraints: interfaces listing allowed types, such as
~int | ~float64, where~includes named types based on them. - Built-in constraints:
anyandcomparable, pluscmp.Orderedin the standard library for types supporting<. - Type inference: callers usually do not need to write type arguments.
Good uses:
- Collection helpers, such as the standard
slicesandmapspackages. - Data structures, such as typed caches, sets and queues.
- Functions that would otherwise take
anyand use type assertions.
When not to use them: when an ordinary interface describing behavior is clearer, or when only one type is ever used. Generic code is harder to read, so use it where it removes real duplication.
Example
One
Sumworks for any integer or floating type, including named types, andMapconverts between types.package main import ( "fmt" "strings" ) 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 Map[T, U any](xs []T, f func(T) U) []U { out := make([]U, 0, len(xs)) for _, x := range xs { out = append(out, f(x)) } return out } type Rupees int64 func main() { fmt.Println(Sum([]int{1, 2, 3})) // 6 fmt.Println(Sum([]Rupees{1500, 250})) // 1750 fmt.Println(Map([]int{1, 2, 3}, func(n int) string { return strings.Repeat("*", n) })) // [* ** ***] }- Type parameters:
Q15.How do strings, bytes and runes differ in Go?
A Go string is an immutable sequence of bytes, usually holding UTF-8 text. A byte is one 8-bit unit, and a rune is one Unicode code point. Characters outside basic English take more than one byte, so byte positions and character positions differ.
string[]byte[]runeImmutable bytes, usually UTF-8 text Mutable bytes Mutable sequence of Unicode code points lencounts byteslencounts byteslencounts code pointsBest for keys, messages and text values Best for building or modifying data, and for input and output Best for character-level processing, such as reversing text Practical rules:
for i, r := range siterates by rune, withias the byte offset of each rune.- Indexing
s[i]returns a byte, not a character. - Converting between
stringand[]byteusually copies the data, so avoid repeated conversions in hot loops. - Use
unicode/utf8to count characters or validate text, and thestringsandbytespackages for searching and splitting. - For building strings piece by piece, use
strings.Builder.
Example
A short string with two non-English characters has 10 bytes but 7 runes.
package main import ( "fmt" "unicode/utf8" ) func main() { s := "naïve ₹" fmt.Println(len(s), utf8.RuneCountInString(s)) for i, r := range "ï₹" { fmt.Println(i, string(r)) } fmt.Println(s[2], string([]rune(s)[2])) } // Output: // 10 7 // 0 ï // 2 ₹ // 195 ïQ16.How do type assertions and type switches work in Go?
A type assertion extracts the concrete value from an interface:
v, ok := x.(T)returns the value andtrueifxholds aT, or the zero value andfalseotherwise. A type switch tests several types in turn and binds a variable of the matching type in each case.Rules:
- The single-result form,
v := x.(T), panics if the type is wrong; use the two-result form unless a mismatch is a bug. - Cases can name interfaces, such as
errororfmt.Stringer, matching any type that implements them. - Cases are checked in order, so put more specific types first.
- A
nilcase matches a nil interface value.
Conversions are different:
float64(n)or[]byte(s)change a value between compatible types at compile time, while assertions inspect an interface's dynamic type at run time.When to use them: decoding loosely typed data, handling special errors, or implementing optional behavior, such as checking whether a writer also supports flushing. Frequent type switches over your own types can signal that an interface method would be a cleaner design.
Example
The switch picks the first matching case, including interface cases such as
errorandfmt.Stringer.package main import ( "errors" "fmt" "time" ) func describe(v any) string { switch x := v.(type) { case nil: return "nil" case int: return fmt.Sprintf("int %d", x) case string: return fmt.Sprintf("string of %d bytes", len(x)) case error: return "error: " + x.Error() case fmt.Stringer: return "stringer: " + x.String() default: return fmt.Sprintf("other %T", x) } } func main() { for _, v := range []any{42, "hi", errors.New("boom"), time.Second, []int{1}, nil} { fmt.Println(describe(v)) } n, ok := any("x").(int) fmt.Println(n, ok) } // Output: // int 42 // string of 2 bytes // error: boom // stringer: 1s // other []int // nil // 0 false- The single-result form,
Q17.How do Go modules manage dependencies and versions?
A module is a collection of packages versioned together, defined by a
go.modfile that names the module and lists its required dependencies with minimum versions. Ago.sumfile records cryptographic hashes of every dependency, so builds detect tampered or changed downloads.How versions are chosen: Go uses minimal version selection: for each dependency, it picks the highest of the minimum versions required anywhere in the build, not the newest available. Builds are therefore reproducible without a separate lock file, and upgrading is always an explicit action.
Everyday commands:
go get example.com/lib@v1.4.2adds or changes a requirement.go mod tidyadds missing and removes unused requirements.go list -m -u allshows available upgrades.go mod whyexplains why a dependency is needed.
Major versions: a breaking release changes the import path, such as
example.com/lib/v2, so two major versions can coexist in one build.Supply-chain practices:
- Review new dependencies before adding them; the standard library often suffices.
- Use the checksum database and a module proxy, or vendoring for strict environments.
- Scan dependencies for known vulnerabilities in continuous integration.
Q18.How do packages, visibility and `internal` directories organize Go code?
A package is a directory of Go files compiled together. Visibility is controlled by capitalization: identifiers starting with an uppercase letter are exported and usable from other packages; lowercase ones are private to the package.
Organizing a service:
- Packages by responsibility, such as
orders,paymentsandstorage, rather than by technical layer names such asmodelsorutils. internaldirectories: packages underinternal/can be imported only by code rooted at the parent directory, which keeps implementation details out of other modules' reach.cmd/directories for each executable, with a smallmainpackage that wires dependencies together.
Naming conventions:
- Short, lowercase package names without underscores, such as
orders. - Avoid repetition:
orders.Service, notorders.OrderService. - Avoid catch-all packages such as
commonorhelpers, which attract unrelated code.
Dependency direction: packages must not import each other in a cycle, which the compiler rejects. Breaking a cycle usually means moving shared types into a smaller package or introducing an interface in the consumer.
- Packages by responsibility, such as
Q19.How does escape analysis decide whether a value lives on the stack or the heap?
The compiler analyzes each value to see whether it can outlive the function that created it. If it cannot, the value stays on the goroutine's stack, which is cheap to allocate and needs no garbage collection. If it might, such as when a pointer to it is returned or stored somewhere longer-lived, it escapes to the heap.
Common reasons values escape:
- Returning a pointer to a local variable.
- Storing a pointer in a global, a struct on the heap or a channel.
- Assigning a value to an interface, which often requires a heap copy.
- Closures that capture variables and outlive the function.
- Slices whose size is not known at compile time, or that are too large for the stack.
How to inspect it: build with
go build -gcflags=-mto print the compiler's escape decisions for each line.Why it matters: heap allocations add garbage collection work, which costs processor time and can raise latency. In hot paths, returning values instead of pointers, reusing buffers and avoiding unnecessary interfaces can remove allocations.
Optimize only after profiling shows allocations matter; most code does not need this attention.
Q20.How does Go's garbage collector work, and how do you tune it?
Go uses a concurrent, non-moving mark-and-sweep collector. It marks reachable objects while the program keeps running, with only very short pauses, then frees unreachable memory. It is designed for low latency rather than maximum throughput.
What controls collection frequency:
GOGC: the heap growth allowed before the next collection, as a percentage; the default of 100 means a collection when the heap doubles since the last one. Higher values use more memory and less processor time.GOMEMLIMIT: a soft limit on total memory, which makes the collector work harder as memory approaches the limit.
Tuning in containers:
- Set
GOMEMLIMITsomewhat below the container's memory limit, such as 90%, so the collector reacts before the container is killed. - With a memory limit set, you can raise
GOGCor turn it off to reduce collection work when memory is plentiful. - Watch for a death spiral: if live data nearly fills the limit, the collector runs constantly and processor use soars.
Reducing pressure is usually better than tuning: fewer allocations, reused buffers and smaller data structures. Measure with the runtime's memory statistics and execution traces before changing settings.