The 60 Go questions interviewers actually ask, with direct answers, runnable code, and what the key signal is. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
Go, often called Golang after its domain, is a statically typed, compiled language designed at Google by Robert Griesemer, Rob Pike, and Ken Thompson, first released in 2009. It aims for the readability of a scripting language with the speed and safety of a compiled one, and it ships a garbage collector, a strict formatter (gofmt), and concurrency built into the language rather than bolted on. Its defining features are goroutines (lightweight threads the runtime schedules onto OS threads) and channels for passing data between them, which is why Go dominates cloud infrastructure: Docker, Kubernetes, and much of the modern backend stack are written in it. In interviews, Go questions probe the memory model (values versus pointers, how slices and maps behave), the concurrency model (the scheduler, data races, the sync package), and idioms like explicit error handling. This page collects the 60 questions that come up most, each with a direct answer and runnable code. If you're building fundamentals, the official Go documentation at go.dev is the canonical reference; increasingly the first Go round is a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: Go Programming - Golang Course with Bonus Projects
Video: Go Programming - Golang Course with Bonus Projects (freeCodeCamp.org, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your Go certificate.
The fundamentals every entry-level Go round checks. If any answer here surprises you, that's your study list.
Go is a statically typed, compiled language from Google, built to make large-scale backend development simple and fast. It compiles to a single static binary, builds in seconds, and has a garbage collector, so you get near-C speed without manual memory management.
It was designed to fix pain the Google teams felt with C++ and Java: slow builds, tangled dependencies, and clumsy concurrency. Go's answers are a tiny language spec, fast compilation, and goroutines that make concurrent code look almost sequential.
Key point: A one-line definition plus the concurrency angle beats a feature list. Interviewers open with this to hear how you organize an answer.
Watch a deeper explanation
Video: Go in 100 Seconds (Fireship, YouTube)
Go is statically typed: every variable has a type known at compile time, and the compiler rejects mismatches before the program runs. It's also strongly typed, so there's no implicit conversion between, say, int and int64; you convert explicitly.
Type inference softens the verbosity. With :=, the compiler infers the type from the value, so you rarely spell types out for locals while still getting compile-time safety.
var count int = 10 // explicit type
name := "Asha" // inferred as string
var total int64 = 5
// total = count // compile error: mismatched types
total = int64(count) // explicit conversion requiredvar declares a variable and works anywhere, including package level, and lets you declare without initializing (the variable takes its zero value). The := short form both declares and assigns, infers the type, and only works inside functions.
Use := for the common in-function case. Reach for var when you need a package-level variable, an explicit type, or a zero-valued declaration you'll assign later.
var ready bool // zero value: false
var port int = 8080 // explicit type at package or function level
func handler() {
count := 0 // short form, function scope only
count++
}Every type has a zero value that a variable gets when declared without an initializer, so there's no uninitialized-memory class of bug. Numeric types zero to 0, bool to false, string to the empty string, and pointers, slices, maps, channels, functions, and interfaces to nil.
This shapes idiomatic Go: a struct is usable right after declaration, and a nil slice behaves like an empty one for range and append. Knowing which zero values are safe to use (nil slice) versus dangerous (nil map on write) is a common follow-up.
var count int // 0
var name string // ""
var active bool // false
var items []string // nil, but ranging and appending are safe
var cache map[string]int // nil: reading is fine, writing panicsKey point: Mention that a nil map panics on write but a nil slice is safe to append to. That distinction is exactly what the follow-up probes.
An array has a fixed length that's part of its type: [3]int and [4]int are different types, and arrays copy by value when passed around. A slice is a lightweight view over an array: a struct holding a pointer, a length, and a capacity, and it can grow.
You almost always use slices. Arrays show up for fixed-size data like hashes or when you want value-copy semantics. Passing a slice shares the backing array, which is efficient but means callees can mutate your data.
var fixed [3]int = [3]int{1, 2, 3} // array, length is part of the type
dynamic := []int{1, 2, 3} // slice
dynamic = append(dynamic, 4) // grows, reassign the result
view := dynamic[1:3] // slice over the same backing array| Array | Slice | |
|---|---|---|
| Length | Fixed, part of the type | Dynamic, grows via append |
| Passing | Copied by value | Header copied, backing array shared |
| Zero value | All-zero array | nil |
| Typical use | Fixed-size data | Almost everything |
Key point: The follow-up is usually 'what does a slice actually contain?'. Answer pointer, length, capacity and you've shown you understand the mechanics.
append adds elements and returns a slice. If there's spare capacity it writes into the existing backing array; if not, it allocates a bigger array, copies the elements, and returns a slice pointing at the new array. Either way the returned header may differ from the original.
That's why you write s = append(s, x). If you ignore the return value and a reallocation happened, your variable still points at the old array and loses the new element.
s := make([]int, 0, 2) // len 0, cap 2
s = append(s, 1) // fits in existing array
s = append(s, 2)
s = append(s, 3) // cap exceeded: new array allocated, elements copied
fmt.Println(len(s), cap(s)) // 3 4Key point: The classic gotcha is sharing a backing array: two slices from the same array can overwrite each other on append. it matters.
A map is Go's hash table: map[K]V gives average O(1) lookup, insert, and delete, and keys must be comparable (so no slices or maps as keys). You create one with make or a literal; the zero value is nil, and writing to a nil map panics.
Reading a missing key returns the value's zero value, so use the comma-ok form to tell 'missing' from 'present but zero'. Map iteration order is deliberately randomized, which trips people who assume insertion order.
ages := map[string]int{"Asha": 31}
ages["Ben"] = 26 // insert
age, ok := ages["Cara"] // age == 0, ok == false
if !ok {
fmt.Println("no entry")
}
delete(ages, "Asha")A value holds the data directly; a pointer holds the memory address of a value. Go passes everything by value, so passing a struct copies it, while passing a pointer copies only the address and lets the callee modify the original.
You take an address with & and dereference with *. Use a pointer when you need to mutate the caller's data or when the value is large enough that copying is wasteful.
func bump(n int) { n++ } // works on a copy, caller unchanged
func bumpP(n *int) { *n++ } // works through the pointer
x := 5
bump(x) // x still 5, the copy was changed
bumpP(&x) // x now 6, modified through the addressA struct groups named fields into one type, Go's equivalent of a lightweight record or class-without-inheritance. You attach behavior with methods that have a receiver, the value or pointer the method operates on.
A value receiver gets a copy; a pointer receiver can modify the original and avoids copying large structs. A common rule: if any method needs a pointer receiver, use pointer receivers for all of that type's methods for consistency.
type Account struct {
Owner string
Balance int
}
func (a Account) Summary() string { // value receiver, read-only
return fmt.Sprintf("%s: %d", a.Owner, a.Balance)
}
func (a *Account) Deposit(n int) { // pointer receiver, mutates
a.Balance += n
}Go treats errors as ordinary values. A function that can fail returns an error as its last result, and the caller checks if err != nil right away. There's no try/catch for expected failures; the explicit check is the idiom.
error is an interface with one method, Error() string. You create errors with errors.New or fmt.Errorf, and wrap them with %w so callers can inspect the chain. panic exists but is reserved for truly unrecoverable states, not routine failures.
func readConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
return data, nil
}
data, err := readConfig("app.yaml")
if err != nil {
log.Fatal(err)
}Key point: the key signal is 'errors are values' and the %w wrapping idiom. Volunteering both signals you write idiomatic, debuggable Go.
defer schedules a function call to run when the surrounding function returns, whether it returns normally or through a panic. Deferred calls run in last-in-first-out order, so the most recent defer fires first.
The everyday use is cleanup that sits right next to acquisition: open a file and immediately defer its Close, lock a mutex and defer Unlock. Arguments to a deferred call are evaluated when defer runs, not when the call fires, which is a frequent gotcha.
func process(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // runs when process returns, even on error
// ... work with f ...
return nil
}A goroutine is a lightweight thread managed by the Go runtime, not the operating system. You start one by putting go before a function call. Goroutines a tiny stack that grows as needed, so a program can run hundreds of thousands of them cheaply comes first.
The runtime multiplexes goroutines onto a small pool of OS threads, so they're far cheaper than OS threads. The catch: main doesn't wait for goroutines, so without synchronization a spawned goroutine may never finish before the program exits.
func greet(name string) {
fmt.Println("hello", name)
}
func main() {
go greet("Asha") // runs concurrently
go greet("Ben")
time.Sleep(time.Millisecond) // crude wait; real code uses sync
}Key point: Note that main won't wait for goroutines. Interviewers love following up with 'so how do you wait for them?', which opens the door to WaitGroup and channels.
Watch a deeper explanation
Video: Concurrency is not Parallelism (gnbitcom, YouTube)
A channel is a typed conduit that goroutines use to send and receive values, giving you both communication and synchronization in one primitive. You make one with make(chan T), send with ch <- v, and receive with v := <-ch.
Channels encode Go's motto: don't communicate by sharing memory, share memory by communicating. Passing data through a channel hands ownership from one goroutine to another, which sidesteps a whole class of data races.
func main() {
results := make(chan int)
go func() {
results <- 42 // send
}()
value := <-results // receive, blocks until a value arrives
fmt.Println(value) // 42
}An unbuffered channel has no capacity: a send blocks until a receiver takes the value, so the two goroutines meet at that point. That handshake makes unbuffered channels a synchronization tool as much as a data pipe.
A buffered channel (make(chan T, n)) holds up to n values. Sends only block when the buffer is full, and receives only block when it's empty. Buffers decouple producer and consumer speed, but a wrong buffer size hides backpressure bugs.
unbuffered := make(chan int) // send blocks until received
buffered := make(chan int, 2) // holds 2 without a receiver
buffered <- 1 // does not block
buffered <- 2 // does not block
// buffered <- 3 // would block: buffer full| Unbuffered | Buffered | |
|---|---|---|
| Capacity | 0 | n (fixed) |
| Send blocks when | No receiver is ready | Buffer is full |
| Main use | Synchronization handshake | Decoupling producer and consumer |
Go has exactly one loop keyword, for, which covers every case. It works as a classic three-clause loop, as a while loop when you keep only the condition, and as an infinite loop when you drop all clauses.
For iterating collections, for ... range walks arrays, slices, maps, strings, and channels, yielding index/value or key/value pairs. Ranging a string yields runes, not bytes, which matters for non-ASCII text.
for i := 0; i < 3; i++ { } // classic
n := 0
for n < 3 { n++ } // while-style
for { break } // infinite, until break
for i, v := range []string{"a", "b"} {
fmt.Println(i, v)
}gofmt is the standard formatter that rewrites source into Go's single canonical style: tabs for indentation, fixed brace placement, aligned struct fields. Editors run it on save, and go fmt applies it from the command line.
Enforcing one style ends formatting debates and makes every Go codebase look familiar, so reviewers focus on logic instead of whitespace. Related tools worth naming: go vet catches suspicious constructs and goimports fixes imports.
Every Go file starts with a package declaration, and code is organized into packages that map to directories. Identifiers starting with a capital letter are exported (public); lowercase ones are package-private. That capitalization rule is the entire access-control system.
You pull in other packages with import, and modules (declared in go.mod) group packages for versioning and dependency management. package main with a func main is the entry point for an executable.
package main
import (
"fmt"
"strings"
)
func main() {
fmt.Println(strings.ToUpper("go")) // exported funcs are capitalized
}A Go string is an immutable sequence of bytes, typically holding UTF-8 text. A byte is an alias for uint8 (one raw byte); a rune is an alias for int32 and represents a single Unicode code point.
This matters because indexing a string gives you a byte, not a character, so len counts bytes. To iterate characters, range over the string (which yields runes) or convert to []rune. Non-ASCII text breaks any code that assumes one byte per character.
s := "héllo"
fmt.Println(len(s)) // 6 bytes, not 5, é is two bytes
for i, r := range s { // r is a rune (code point)
fmt.Printf("%d:%c ", i, r)
}Key point: Interviewers use the len("héllo") == 6 surprise to test whether you understand UTF-8. Explaining bytes versus runes is the win.
new(T) allocates zeroed memory for a T and returns a pointer to it (*T). It works for any type but leaves the value at its zero state. make is only for slices, maps, and channels, and it returns an initialized (not just zeroed) value, not a pointer.
The reason for two builtins: slices, maps, and channels need internal setup (a backing array, a hash table, a buffer) that a plain zeroed allocation doesn't provide. So you make them and new the rest, though new is rarely used in practice.
p := new(int) // *int pointing at 0
*p = 5
s := make([]int, 0, 4) // initialized slice, len 0, cap 4
m := make(map[string]int) // usable map (a nil map would panic on write)Go's switch doesn't fall through by default, so you don't write break after every case. If you actually want fall-through, you ask for it with the fallthrough keyword. Cases can be expressions, not just constants.
A switch with no condition acts like a clean if/else-if chain, and a type switch branches on an interface's dynamic type. That flexibility makes switch far more common in Go than in C-family languages.
switch { // conditionless: if/else chain
case score >= 90:
grade = "A"
case score >= 80:
grade = "B"
default:
grade = "C"
}For candidates with working experience: interfaces, concurrency patterns, the standard library, and the questions that separate users from understanders.
An interface is a set of method signatures. A type satisfies it automatically by having those methods, with no implements keyword and no explicit declaration. This structural typing means a type can satisfy an interface defined in a package it's never heard of.
The payoff is loose coupling: you define small interfaces where you consume behavior, not where types are declared, so packages depend on capabilities rather than concrete types. That's why idiomatic Go favors tiny interfaces like io.Reader over broad ones.
type Stringer interface {
String() string
}
type Point struct{ X, Y int }
func (p Point) String() string { // Point now satisfies Stringer
return fmt.Sprintf("(%d, %d)", p.X, p.Y)
}
var s Stringer = Point{1, 2} // works, no declaration neededKey point: Say 'accept interfaces, return structs' and give io.Reader as the example. That one line signals you've read idiomatic Go, not just the spec.
The empty interface has no methods, so every type satisfies it. It's the way to hold a value of unknown type, and Go 1.18 added any as an alias to make the intent readable. json.Unmarshal into a map and fmt.Println's parameters both rely on it.
Use it sparingly. Reaching for any throws away the compiler's type checking and forces type assertions or reflection at runtime. Since generics arrived, most 'works with any type' code should use type parameters instead.
func describe(v any) string {
switch x := v.(type) {
case int:
return fmt.Sprintf("int %d", x)
case string:
return fmt.Sprintf("string %q", x)
default:
return "unknown"
}
}A type assertion x.(T) extracts the concrete type T from an interface value. The two-result form v, ok := x.(T) is the safe version: ok is false instead of panicking when the type doesn't match. The single-result form panics on a mismatch.
A type switch tests an interface value against several types in one construct, binding the matched value in each case. Use an assertion when you expect one specific type, and a type switch when you're branching over several.
var i any = "hello"
s, ok := i.(string) // s == "hello", ok == true
n, ok := i.(int) // n == 0, ok == false (no panic)
switch v := i.(type) {
case string:
fmt.Println("string of length", len(v))
case int:
fmt.Println("int", v)
}Use sync.WaitGroup. Call Add(n) before launching goroutines, have each goroutine call Done when it finishes (usually via defer), and call Wait in the launcher to block until the counter hits zero.
The rule that catches people: call Add before starting the goroutine, not inside it, or Wait may race past before the counter is set. Pass the WaitGroup by pointer, since copying it breaks the shared counter.
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
fetch(u)
}(url)
}
wg.Wait() // blocks until all goroutines call DoneKey point: Passing the loop variable as an argument (or, in older Go, copying it) is the detail interviewers check. Getting the closure capture right shows real concurrency experience.
Use a sync.Mutex to protect shared state that multiple goroutines read and write, like a counter or a cache. Lock before touching the data, Unlock after, and defer the Unlock so it runs even on an early return.
The guideline: channels for passing ownership of data between goroutines, mutexes for guarding shared memory that stays in place. A RWMutex lets many readers proceed together while writes stay exclusive, which helps read-heavy caches.
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}Key point: the question needs to hear the split: channels for communication, mutexes for shared state. Reciting 'share memory by communicating' without the nuance sounds memorized.
select waits on multiple channel operations at once and proceeds with whichever is ready. If several are ready it picks one at random; if none are and there's a default case, it runs that immediately, making the operation non-blocking.
It's the backbone of Go concurrency patterns: timeouts (race a work channel against time.After), cancellation (watch a done or context channel), and fan-in (merge several channels into one).
select {
case msg := <-work:
process(msg)
case <-time.After(2 * time.Second):
fmt.Println("timed out")
case <-ctx.Done():
return ctx.Err()
}context carries cancellation signals, deadlines, and request-scoped values across API boundaries and goroutines. A parent creates a context, passes it down, and any goroutine can select on ctx.Done() to stop work when the request is cancelled or times out.
It's how you avoid leaking goroutines that keep running after their caller has given up. The convention: pass ctx as the first parameter, never store it in a struct, and don't stuff arbitrary data into context values.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req) // aborts if ctx expiresKey point: Interviewers probe whether you thread context through call chains. Saying 'first parameter, propagate everywhere, cancel on defer' marks you as someone who has debugged goroutine leaks.
A data race happens when two goroutines access the same memory concurrently and at least one is writing, with no synchronization between them. The result is undefined: corrupted values, torn reads, or crashes that appear only under load.
Go ships a race detector: run tests or the program with the -race flag and it instruments memory access to report the exact conflicting accesses and their stack traces. Fix races with a mutex, a channel, or the sync/atomic package.
// go run -race main.go flags this:
counter := 0
for i := 0; i < 100; i++ {
go func() { counter++ }() // concurrent unsynchronized write: a data race
}Key point: the -race flag is useful because is a strong signal. Interviewers know that engineers who've shipped concurrent Go reach for it reflexively.
Spawn a fixed number of goroutines that all read from one jobs channel and write to a results channel. This caps concurrency, which protects downstream resources like a database or an external API from being overwhelmed by unbounded goroutines.
The shape: send jobs, close the jobs channel to signal no more work, and use a WaitGroup or a counted receive on results to know when everything's done. Closing the channel is what lets each worker's range loop exit cleanly.
func worker(jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs { // exits when jobs is closed
results <- j * j
}
}
jobs := make(chan int, 100)
results := make(chan int, 100)
var wg sync.WaitGroup
for w := 0; w < 3; w++ {
wg.Add(1)
go worker(jobs, results, &wg)
}Worker pool lifecycle
The fixed worker count is the point: it bounds concurrency so a burst of jobs can't spawn unbounded goroutines.
You can restrict a channel parameter to send-only (chan<- T) or receive-only (<-chan T). The compiler then rejects the wrong operation, so a function that's supposed to only produce values can't accidentally read from the channel.
It's a documentation and safety tool. A signature like func worker(in <-chan Job, out chan<- Result) tells the reader exactly how data flows and lets the compiler enforce it.
func produce(out chan<- int) { // send-only
for i := 0; i < 3; i++ {
out <- i
}
close(out)
}
func consume(in <-chan int) { // receive-only
for v := range in {
fmt.Println(v)
}
}Close a channel to broadcast that no more values will be sent, which lets receivers' range loops exit and the comma-ok receive return ok == false. The sender closes, never the receiver, and only one goroutine should own the close.
Closing is not required for garbage collection; it's a signal. Sending on a closed channel panics, and so does closing an already-closed one, so ownership must be clear. For fan-out with multiple senders, coordinate the The closing step is a WaitGroup.
ch := make(chan int)
go func() {
ch <- 1
ch <- 2
close(ch) // sender closes
}()
for v := range ch { // loop ends when ch is closed and drained
fmt.Println(v)
}
v, ok := <-ch // v == 0, ok == false after closeEmbedding puts one type inside another without a field name, and the outer type promotes the embedded type's fields and methods as if they were its own. It's composition: the outer type gains behavior by containing another, not by subclassing it.
Unlike inheritance there's no is-a relationship or virtual dispatch. If both the outer and embedded types define a method, the outer one wins, and you can still reach the embedded method explicitly. Embedding an interface is how you compose interfaces too.
type Logger struct{ prefix string }
func (l Logger) Log(msg string) { fmt.Println(l.prefix, msg) }
type Service struct {
Logger // embedded, no field name
name string
}
s := Service{Logger{"[svc]"}, "auth"}
s.Log("started") // promoted method, prints "[svc] started"Wrap an error with fmt.Errorf and the %w verb to add context while keeping the original reachable. Then errors.Is checks whether a target error is anywhere in the chain, and errors.As extracts a specific error type from it.
This replaced the old string-matching habit. Wrapping keeps a readable trail (open config: read file: permission denied) while letting callers branch on the underlying cause programmatically rather than by parsing messages.
var ErrNotFound = errors.New("not found")
func load(id string) error {
return fmt.Errorf("load %s: %w", id, ErrNotFound)
}
err := load("42")
if errors.Is(err, ErrNotFound) { // true, unwraps the chain
fmt.Println("handle missing record")
}Key point: errors.Is and errors.As are the modern idioms. %w preserves the chain (versus %v which flattens it).
panic stops normal flow and unwinds the stack, running deferred functions as it goes; if nothing stops it, the program crashes. recover, called inside a deferred function, catches a panic and lets the goroutine resume in a controlled way.
Reserve them for genuinely unrecoverable states (programmer bugs, corrupt invariants) or for converting a panic into an error at a boundary, like a server's request handler that shouldn't crash the whole process. Ordinary errors use returned error values, not panic.
func safeHandler(fn func()) {
defer func() {
if r := recover(); r != nil {
log.Printf("recovered: %v", r)
}
}()
fn()
}Generics (added in Go 1.18) let functions and types take type parameters constrained by an interface, so one implementation works across many types with full compile-time checking. The constraint says which types are allowed; comparable and constraints.Ordered are common ones.
Before generics you either wrote the same function per type or used interface{} and lost type safety. Now a Map or a Min function is written once and stays type-safe. Use generics for genuinely type-agnostic containers and algorithms, not everywhere.
func Map[T, U any](s []T, f func(T) U) []U {
result := make([]U, len(s))
for i, v := range s {
result[i] = f(v)
}
return result
}
nums := []int{1, 2, 3}
doubled := Map(nums, func(n int) int { return n * 2 }) // [2 4 6]The encoding/json package marshals structs to JSON and unmarshals JSON into structs. Struct tags like `json:"user_name"` map Go field names to JSON keys, and options like omitempty drop zero-valued fields. Only exported (capitalized) fields are encoded.
Unmarshalling into any gives you map[string]interface{} and float64 for numbers, which is why decoding into a typed struct is preferred. json.Decoder streams from an io.Reader for large payloads instead of buffering everything.
type User struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
}
u := User{Name: "Asha"}
data, _ := json.Marshal(u) // {"name":"Asha"}
var back User
json.Unmarshal(data, &back) // pointer requiredGo has testing built in. Put tests in files ending _test.go, write functions named TestXxx(t *testing.T), and run them with go test. There's no assertion library in the standard toolkit; you compare values and call t.Errorf or t.Fatalf on failure.
Table-driven tests are the idiom: a slice of cases looped with t.Run for named subtests. Benchmarks (BenchmarkXxx) and the -race flag round out the workflow, and go test -cover reports coverage.
func TestAdd(t *testing.T) {
cases := []struct {
a, b, want int
}{
{1, 2, 3},
{0, 0, 0},
}
for _, c := range cases {
if got := Add(c.a, c.b); got != c.want {
t.Errorf("Add(%d,%d) = %d, want %d", c.a, c.b, got, c.want)
}
}
}A module is a collection of packages versioned together, defined by a go.mod file at its root that lists the module path, the Go version, and dependency requirements. go mod init creates it; go get adds or updates dependencies.
go.sum records cryptographic checksums so builds are reproducible and tamper-evident. Modules use semantic versioning and minimal version selection, meaning the build picks the lowest version that satisfies all requirements, which keeps upgrades predictable.
// go.mod
module github.com/acme/billing
go 1.22
require (
github.com/google/uuid v1.6.0
)There's no built-in remove. To delete index i while keeping order, append the tail onto the head: s = append(s[:i], s[i+1:]...). If order doesn't matter, swap the element with the last one and shrink, which is O(1) instead of O(n).
The gotcha is memory: the removed element's slot may still hold a reference, keeping a pointed-at object alive. For slices of pointers, zero the freed slot before shrinking so the garbage collector can reclaim it.
// keep order (O(n))
s = append(s[:i], s[i+1:]...)
// order-agnostic (O(1))
s[i] = s[len(s)-1]
s = s[:len(s)-1]A goroutine leaks when it blocks forever, usually waiting to send or receive on a channel that nothing will ever service, so it never returns and its memory is never freed. Enough leaks and a long-running service climbs in memory and goroutine count until it falls over.
Prevent it by giving every goroutine a way to exit: watch a context's Done channel, use select with a cancellation case, and make sure someone always drains or closes the channels a goroutine waits on. Never start a goroutine without knowing how it stops.
func worker(ctx context.Context, jobs <-chan int) {
for {
select {
case <-ctx.Done():
return // exit path prevents a leak
case j := <-jobs:
process(j)
}
}
}Key point: The phrase interviewers wait for is 'every goroutine needs an exit path'. Pairing it with the context.Done pattern shows you've operated Go in production.
advanced rounds probe the runtime, design judgment, and production scars. Expect every answer here to draw a follow-up.
Go uses an M:N scheduler that multiplexes goroutines (G) onto OS threads (M) via logical processors (P), the GMP model. Each P holds a run queue of goroutines and needs an M to run them; GOMAXPROCS sets how many Ps run Go code at once.
It's cooperative with preemption support: goroutines yield at function calls and channel operations, and the runtime can preempt long-running loops so one goroutine can't starve the others. Work stealing keeps Ps busy: an idle P pulls goroutines from a loaded P's queue.
Key point: Sketching the G, M, and P letters and explaining work stealing is what separates The production-ready answer from a hand-wave. Interviewers probe whether you understand why goroutines are cheap.
Watch a deeper explanation
Video: GopherCon 2018: The Scheduler Saga (Gopher Academy, YouTube)
The Go memory model defines when a read is guaranteed to see a write from another goroutine, expressed as happens-before relationships. Synchronization operations (channel send/receive, mutex lock/unlock, sync/atomic) establish those relationships; without them, one goroutine may never observe another's writes.
It matters because unsynchronized shared access isn't just risky, it's undefined: the compiler and CPU may reorder or cache values. This is the formal reason a data race is a bug even when it seems to work; correctness requires an explicit synchronization edge, not luck.
Go uses a concurrent, tri-color mark-and-sweep collector that runs alongside your program to keep pause times low, typically well under a millisecond. It marks reachable objects while the app runs, using a write barrier to track pointers that change mid-cycle, then sweeps the unreachable ones.
You tune it mostly through GOGC (the heap-growth trigger) and, in recent Go, a soft memory limit. The practical lever is allocation: fewer allocations mean less GC work, which is why pooling with sync.Pool and avoiding needless heap escapes matter under load.
Escape analysis is the compiler pass that decides whether a value can live on the goroutine's stack or must move to the heap. A value escapes when its lifetime outlives the function, for example when you return a pointer to a local or store it somewhere reachable later.
Stack allocation is cheap and needs no garbage collection, so keeping values on the stack reduces GC pressure. You inspect decisions with go build -gcflags=-m. Chasing every escape is premature, but on hot paths, avoiding unnecessary heap allocation is a real win.
// go build -gcflags=-m shows this pointer escaping to the heap
func newUser(name string) *User {
u := User{Name: name} // u escapes because we return its address
return &u
}An interface value is a pair: a type and a value. It's nil only when both halves are nil. If you assign a nil pointer of a concrete type to an interface, the type half is set, so the interface compares as non-nil even though the underlying pointer is nil.
This bites when a function returns an error interface holding a typed nil: if err != nil passes unexpectedly. The fix is to return a literal nil, not a typed nil pointer, and to type error-returning functions carefully.
type MyErr struct{}
func (*MyErr) Error() string { return "boom" }
func doWork() error {
var e *MyErr = nil
return e // returns a non-nil interface holding a nil pointer
}
if doWork() != nil { // true, the classic surprise
fmt.Println("looks like an error, but the pointer is nil")
}Key point: This is a favorite senior trap. Explaining the (type, value) pair, not just the symptom, is what earns the point.
Watch a deeper explanation
Video: GopherCon 2016: Understanding nil (Gopher Academy, YouTube)
sync.RWMutex allows many concurrent readers or one writer, good for read-heavy shared state. sync.Once runs an initialization exactly once across goroutines, the idiomatic singleton setup. sync.WaitGroup coordinates completion, and sync.Pool reuses temporary objects to cut allocations.
The judgment: RWMutex only helps when reads truly dominate (writer starvation is a risk otherwise), Once beats hand-rolled double-checked locking, and Pool is for short-lived allocation-heavy objects, not a general cache since the GC can drain it at any time.
| Primitive | Use it for | Watch out for |
|---|---|---|
| RWMutex | Read-heavy shared state | Writer starvation under constant reads |
| Once | One-time initialization | The function runs once even if it errors |
| WaitGroup | Waiting for goroutines | Add before launch, pass by pointer |
| Pool | Reusing temporary objects | GC can empty it anytime, not a cache |
Use sync/atomic for single-word operations on one variable, like a counter or a flag, where you need lock-free updates. Atomic add, load, store, and compare-and-swap are cheaper than locking because they map to single CPU instructions with no contention on a mutex.
The line: atomics protect one value with one operation; the moment you need to update several fields together as an invariant, you need a mutex. The atomic.Value and typed atomics (atomic.Int64) added in recent Go make the common cases cleaner.
var counter atomic.Int64
for i := 0; i < 1000; i++ {
go func() {
counter.Add(1) // lock-free, safe across goroutines
}()
}
fmt.Println(counter.Load())Chain stages where each is a function taking an input channel and returning an output channel, running its work in a goroutine. Stage one generates, stage two transforms, stage three collects, and each closes its output channel when its input is drained so downstream loops end cleanly.
For correctness under cancellation, thread a done or context channel through every stage and select on it, so an early exit doesn't leak the goroutines feeding later stages. This is the Go concurrency pattern from Rob Pike's talks.
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
out <- n * n
}
}()
return out
}
for v := range square(gen(2, 3, 4)) {
fmt.Println(v) // 4 9 16
}A three-stage pipeline
Thread a context or done channel through every stage so cancellation stops the whole pipeline without leaking goroutines.
Use a pointer receiver when the method mutates the receiver or the struct is large enough that copying is wasteful; use a value receiver for small, immutable types. Keep it consistent per type: mixing receiver kinds on one type confuses readers and the method set rules.
The subtle part: methods with pointer receivers are in the method set of *T only, not T. So a value stored in an interface won't satisfy an interface whose methods have pointer receivers unless you store a pointer. This causes 'does not implement' errors that surprise people.
type Speaker interface{ Speak() string }
type Dog struct{}
func (d *Dog) Speak() string { return "woof" } // pointer receiver
// var s Speaker = Dog{} // compile error: Dog does not implement Speaker
var s Speaker = &Dog{} // works: *Dog is in the method setKey point: The method-set rule is a genuine senior discriminator. If you can explain why &Dog{} works but Dog{} doesn't, you understand Go's type system.
Go has pprof built in. For a service, import net/http/pprof to expose live CPU, heap, goroutine, and block profiles over HTTP; for tests, use go test -cpuprofile / -memprofile. Then go tool pprof reads the profile into flame graphs, top lists, and call trees.
The discipline is to measure before optimizing: profile a realistic workload, find the actual hot spots (they're rarely where you'd guess), fix in order of impact, and re-profile to confirm. Goroutine and block profiles are the ones that catch concurrency stalls.
import _ "net/http/pprof"
func main() {
go http.ListenAndServe("localhost:6060", nil)
// then: go tool pprof http://localhost:6060/debug/pprof/heap
runService()
}Create one context at the request's entry (a server handler already provides r.Context()), derive timeouts or deadlines with context.WithTimeout, and pass that context down through every function and goroutine. Each blocking operation selects on ctx.Done() so cancellation propagates instantly.
The design rule is one context tree per request: cancelling the parent cancels every child, so a client hang-up or a hit deadline tears down all the goroutines and downstream calls that request spawned. Always call the cancel func (defer cancel()) to release resources even on the success path.
A constraint is an interface that lists either methods or a set of allowed types (using the ~ token to include types whose underlying type matches). The constraints package and comparable cover common cases; you write custom constraints for numeric or ordered operations.
Avoid generics when a plain interface expresses the behavior more clearly, or when only one type is ever involved. Overusing type parameters makes signatures noisy and code harder to read. The Go team's own guidance is to reach for generics for containers and algorithms, not reflexively.
type Number interface {
~int | ~int64 | ~float64 // ~ includes named types with these underlyings
}
func Sum[T Number](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}Listen for termination signals (SIGINT, SIGTERM) with signal.NotifyContext, then on receipt stop accepting new work and drain the in-flight work within a deadline. For an HTTP server, http.Server.Shutdown does exactly that: it stops listeners and waits for active requests to finish, up to the context's timeout.
The full picture also closes database pools, flushes buffered writers and logs, and cancels the root context so background goroutines exit. A hard deadline guards against a request that never finishes, after which you force-exit rather than hang forever.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go srv.ListenAndServe()
<-ctx.Done() // wait for a signal
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx) // drain in-flight requestsKeep interfaces small and define them where they're used, not where types are implemented. The consumer declares the minimal behavior it needs (often one method), and any type that already has that method satisfies it for free. io.Reader and io.Writer are the models.
This inverts the Java habit of defining interfaces alongside implementations. In Go, big upfront interface hierarchies are a smell. Small consumer-side interfaces keep packages decoupled, make mocking in tests trivial, and let implementations evolve without touching callers.
Key point: 'Define interfaces where you use them' plus io.Reader as evidence is the answer that indicates design maturity. Broad interface hierarchies signal someone applying Java patterns to Go.
No. A built-in map is not safe for concurrent writes; concurrent write access triggers a runtime fatal error that even recover can't catch, by design, so the bug surfaces loudly. Concurrent reads alone are fine.
The options: guard the map with a sync.RWMutex (simple and usually best), or use sync.Map for the specific pattern of many goroutines with disjoint keys or write-once-read-many access. sync.Map trades type safety and generality for that narrow win, so the mutex-wrapped map is the default.
type SafeMap struct {
mu sync.RWMutex
m map[string]int
}
func (s *SafeMap) Get(k string) (int, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
v, ok := s.m[k]
return v, ok
}context.Value is for request-scoped data that crosses API boundaries, like a request ID or auth token, not for passing optional function parameters. Overusing it hides dependencies, defeats compile-time checking (values are any), and makes code hard to follow.
Pass real parameters explicitly and reserve context values for a handful of cross-cutting, request-scoped items. Use a private key type to avoid collisions, and never store a database handle or a logger config in context just to avoid threading it through signatures.
type ctxKey string
const requestIDKey ctxKey = "requestID" // private key type avoids collisions
ctx := context.WithValue(parent, requestIDKey, "abc-123")
id, _ := ctx.Value(requestIDKey).(string)Write a BenchmarkXxx(b *testing.B) function that runs the code b.N times, and run it with go test -bench. The framework picks b.N to get a stable timing. Report allocations too with -benchmem, since allocation count often matters more than raw nanoseconds.
The classic mistakes: letting the compiler optimize away work whose result is unused (assign it to a package-level sink), including setup inside the timed loop (use b.ResetTimer), and benchmarking an unrealistic input size. Always compare against a baseline, not in isolation.
var sink int // prevents the compiler from eliminating the work
func BenchmarkSum(b *testing.B) {
data := makeData(1000)
b.ResetTimer() // exclude setup from the measurement
for i := 0; i < b.N; i++ {
sink = Sum(data)
}
}Add a default case to select. If no channel is ready, select runs default immediately instead of blocking. That gives you non-blocking sends and receives, useful for try-send, polling, and shedding load when a buffer is full.
Common patterns built on this: a non-blocking send that drops a value rather than blocking a producer, and a heartbeat check that reads a channel only if data is waiting. Overusing default turns into busy-waiting, so pair it with a timer or a blocking case when you actually want to wait.
select {
case ch <- value:
// sent successfully
default:
// buffer full: drop the value instead of blocking
metrics.Inc("dropped")
}Clarify the requirements first (capacity, thread safety, TTL), then combine a map for O(1) lookup with a doubly linked list for O(1) recency updates: the map points to list nodes, the list orders entries by use, and eviction removes the tail. The standard container/list gives you the list.
Wrap it in a mutex for concurrent access, and on every Get move the node to the front; on Put past capacity, evict the back. The closing step is the operational edges: whether to evict on TTL, metrics for hit rate, and whether sharding reduces lock contention under heavy load.
type LRU struct {
mu sync.Mutex
cap int
ll *list.List // recency order
items map[string]*list.Element // O(1) lookup
}
func (c *LRU) Get(key string) (any, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if el, ok := c.items[key]; ok {
c.ll.MoveToFront(el) // mark most recently used
return el.Value, true
}
return nil, false
}Key point: The map-plus-linked-list answer is what the technical evaluation checks. Structuring it as clarify, data structures, concurrency, edges shows the design method, not just the trivia.
Observe first: check the goroutine count and heap over time (a steady climb points to a leak), then pull live profiles from pprof without restarting, a goroutine profile for stuck goroutines, heap for allocation growth, CPU for hot spots. Correlate with recent deploys and traffic changes.
Then hypothesize and confirm: blocked goroutines usually mean a channel with no receiver or a missing context cancellation; rising heap means an unbounded cache or a goroutine holding references. Fix at the right layer and re-check the same metric. The method matters more than any single tool.
Key point: The methodology is (observe, hypothesize, verify, fix, confirm); pprof and the goroutine count are supporting evidence.
Go wins when you want simple, fast concurrent services that compile to a single static binary and stay easy to read across a large team. It trades expressive power (no inheritance, deliberately few features) for a small language you can learn in a weekend and hand to any engineer. Where it loses is raw ergonomics for heavy generic or functional code, and control over memory: the garbage collector and runtime rule out the hard real-time and zero-overhead niches that Rust owns. Saying these trade-offs out loud is itself an interview signal: it shows you pick tools on merits, not habit.
| Language | Typing | Concurrency model | Best at | Watch out for |
|---|---|---|---|---|
| Go | Static, compiled | Goroutines and channels | Cloud services, CLIs, networked backends | Verbose error handling, GC pauses under extreme load |
| Java | Static, JVM | Threads, virtual threads | Large enterprise systems, Android | JVM startup and memory footprint |
| Python | Dynamic, interpreted | asyncio, GIL-bound threads | Data, ML, scripting, prototyping | Slower runtime, CPU parallelism (GIL) |
| Rust | Static, compiled | async, no data races at compile time | Systems code, zero-overhead performance | Steeper learning curve, longer build times |
Prepare in layers, and practice out loud. Most Go rounds move from concept questions to live coding to a concurrency or design discussion, so rehearse each stage rather than only reading answers.
The typical Go interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These Go questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works