Menu

Earn Premium with Referrals

Invite your friends and earn Premium rewards through our referral program.

See how it works and start inviting friends.

Top 25 - Part 1
GO

Top 25 - Part 1

Practice the first 15 questions from a curated set of the top 25 Go programming interview questions.

1. What is the execution order of multiple deferred functions inside the same function scope?

Answer: LIFO — Last In, First Out. The last deferred call runs first.

Each defer statement pushes its function onto a per-function stack. When the surrounding function returns, the runtime pops that stack, executing deferred calls in reverse order — the most recently deferred runs first.

This is deliberate, and it supports the classic cleanup pattern:

func copyFile() {
    f, _ := os.Open(src)
    defer f.Close()
    g, _ := os.Create(dst)
    defer g.Close()
    // ...
}

Resources acquired later are released first — g closes before f — which is exactly the right order for dependent resources (you close the destination before the source, matching acquisition in reverse). The LIFO ordering is guaranteed, not incidental.

The interview answer: multiple defers run in LIFO order, like a stack being popped.

2. What is the key structural difference between make() and new() in Go?

Answer: make() creates and initializes the built-in reference types (slices, maps, channels) and returns the initialized type itself. new() allocates zeroed storage for any type and returns a pointer to it.

These two built-ins solve different problems and are often confused.

new(T):

  • Allocates zeroed memory for type T.
  • Returns a pointer *T to that zeroed value.
  • new(int) gives you *int pointing to 0. It works for any type, but for slices/maps/channels it’s almost useless: new([]int) is a *[]int pointing to a nil slice.

make(T, ...):

  • Only works for slices, maps, and channels.
  • Initializes the internal data structure — a slice gets its backing array, a map its buckets, a channel its buffer.
  • Returns the initialized value (a []int, a map[K]V, a chan T), not a pointer.

The mental model: new gives you zeroed memory; make gives you a usable reference type. You’d write make([]int, 5) for a slice and new(int) for a pointer to an int — and you’d essentially never write new([]int).

3. What will the following loop print?

for i := 0; i < 3; i++ {
    defer func() {
        fmt.Print(i, " ")
    }()
}

Output: 3 3 3

The deferred functions are closures — they capture the variable i, not the value of i at the time the defer executed. All three closures reference the same loop variable.

The defers run after the loop finishes, in LIFO order. By then, the loop has run i through 0, 1, 2 and the loop condition (i < 3) has pushed it to 3. Every closure reads the shared variable’s current value — 3. Each prints 3 .

This was such a common bug that Go 1.22 changed loop variable semantics (each iteration now gets its own variable). Before 1.22, the output here was 3 3 3. To get 2 1 0 under old semantics you’d capture the value explicitly:

defer func(v int) { fmt.Print(v, " ") }(i)

The interview point: closures capture variables, not values — combined with LIFO defer, a loop of deferred closures sees the final loop value.

4. What is the scope of a variable declared using := inside an if initialization statement?

Answer: It’s scoped to the if statement and all its accompanying else if / else branches.

Go allows an initialization statement in an if:

if err := doWork(); err != nil {
    // err in scope here
} else {
    // and here
}
// err NOT in scope here

The variable declared by := exists from the initialization through the end of the entire if-chain — the body, the else if clauses, and the else block. After the closing brace, it’s gone.

This is the idiomatic Go error-handling pattern: check an error, use it in the body, and don’t pollute the surrounding function scope. It compiles to clean, readable error handling.

The interview answer: the initialized variable lives only within the if (and its else/else if) block.

5. How does Go handle method receiver mechanics for value vs pointer receivers?

Answer: A value receiver works on a copy of the struct; a pointer receiver works on the original instance, so mutations persist.

When you define a method, you choose its receiver:

func (s Struct) Set(x int)     // value receiver — s is a copy
func (s *Struct) Set(x int)    // pointer receiver — s references the original

With a value receiver, Go passes a copy of the struct. Any field modification inside the method affects only that copy; the caller’s struct is untouched. Value receivers are appropriate for methods that only read state.

With a pointer receiver, the method gets the address of the original struct, so field assignments inside the method mutate the caller’s instance and persist after the call returns.

Two practical implications:

  • Mutating methods must use pointer receivers.
  • Method sets differ: pointer receivers make the method part of the *T method set (callable on both T and *T), while value receivers are in the T method set. This matters when a type must satisfy an interface with pointer receivers — only a *T value qualifies.

The interview answer: value receivers operate on a copy; pointer receivers operate on and mutate the original.

6. What is the output of the following channel selection snippet?

ch1 := make(chan string, 1)
ch2 := make(chan string, 1)
ch1 <- "one"
ch2 <- "two"

select {
case msg1 := <-ch1:
    fmt.Println(msg1)
case msg2 := <-ch2:
    fmt.Println(msg2)
}

Answer: Randomly chooses between "one" and "two".

When multiple cases in a select are ready at the same time, Go does not prefer one. It picks pseudo-randomly among the ready cases — a deliberate design choice to keep scheduling fair.

This randomization exists to prevent starvation: if select always chose the first ready case, a hot channel could starve the others forever. By randomizing, Go guarantees that over time all ready channels get served.

If exactly one case is ready, it runs; if none is ready, select blocks (or, with a default, runs the default). Here both channels hold a value, so both cases are ready — and the answer is “either, chosen at random,” not a deterministic "one" or "two".

7. What will fmt.Println(a == b) produce for these slice declarations?

var a []int
b := []int{}

Answer: Compilation error — slices are not comparable with == (except against nil).

A slice is a descriptor (pointer + length + capacity), and Go forbids comparing two slices with ==. There’s no meaningful value-equality for slices: should a == b compare lengths? Contents? Underlying arrays? Go sidesteps the ambiguity by making the comparison a compile-time error.

The one exception: a slice can be compared to nila == nil — which tests whether the slice header has no backing array. (Here a is nil and b is an empty non-nil slice, but you can’t even ask.)

If you need to compare slice contents, you use the explicit tool: slices.Equal(a, b) (Go 1.21+) or reflect.DeepEqual(a, b).

The interview answer: a compile error — slices aren’t ==-comparable, only nil-comparable.

8. How can you safely check if a key exists in a map without triggering a zero-value fallback?

Answer: Use the comma-ok idiom: if val, ok := m["key"]; ok { ... }.

Reading m["key"] alone is ambiguous: a missing key and a key mapped to the zero value both return the zero value, so you can’t tell which happened.

The two-value form resolves it:

val, ok := m["key"]
  • ok == true — the key exists; val is its value.
  • ok == false — the key is absent; val is the zero value.

This is the standard, safe way to distinguish “present with value 0” from “not present at all.” The interview answer: the val, ok := m[key]; ok idiom.

9. What is the size of struct{} (an empty struct) in Go?

Answer: 0 bytes.

struct{} is the empty struct — a type with no fields. An instance of it occupies zero bytes of memory. Go explicitly defines this: all empty structs share the same zero-sized representation, often a single shared address.

This makes it a perfect set-as-value signal. Two famous uses:

set := make(map[string]struct{})   // a set — keys matter, values cost nothing
ch := make(chan struct{})           // a "signal" channel — value carries no data

For the map, you only care about key presence; struct{} values waste nothing. For the channel, you’re using it purely for synchronization — close(ch) as a broadcast to all receivers, or ch <- struct{}{} as a “done” ping.

The interview answer: struct{} is exactly 0 bytes, and it’s used to signal presence without storage.

10. What is a “deadlock” panic in Go?

Answer: The runtime detects that all goroutines are blocked — none can make progress — and crashes with fatal error: all goroutines are asleep - deadlock!

A deadlock happens when every goroutine is waiting on something that can never be satisfied: a channel send with no receiver, a receive with no sender, a mutex nobody will release, or circular waits between goroutines.

Go’s runtime actively watches for this. When it determines that the entire process is stuck — no goroutine runnable, all blocked — there’s no point continuing, so it raises the fatal error. Like concurrent map access, this is not recoverable via panic/recover; it terminates the program.

The classic trigger is a main goroutine blocked forever: sending on a channel nobody will receive from, with no other goroutines running. The runtime message tells you exactly what’s deadlocked.

The interview answer: a deadlock panic fires when all goroutines are asleep/blocked and no progress is possible — a fatal, non-recoverable runtime error.

11. Which function from the sync package allows waiting for a collection of goroutines to finish executing?

Answer: sync.WaitGroup.

WaitGroup coordinates a set of goroutines: the main goroutine blocks until all workers complete. Its three methods form the whole API:

var wg sync.WaitGroup
wg.Add(2)                    // two goroutines to wait for
go func() { defer wg.Done(); work() }()
go func() { defer wg.Done(); work() }()
wg.Wait()                    // blocks until both Done() calls
  • Add(n) — declares how many goroutines to wait for (ideally before starting them).
  • Done() — called when a goroutine finishes; it decrements the counter.
  • Wait() — blocks until the counter reaches zero.

The counter must not go negative, and Wait must not be called while Add is still racing — the standard pattern is Add before go, defer Done() inside each goroutine.

The interview answer: sync.WaitGroup, with its Add / Done / Wait trio.

12. What happens if you try to lock a sync.Mutex that is already locked by the same goroutine?

Answer: The goroutine blocks forever on itself — a deadlock. Go’s mutexes are non-reentrant.

sync.Mutex does not track which goroutine holds it, and it does not allow re-entry. When a goroutine that already holds the lock calls Lock() again on the same mutex, it tries to acquire a lock that will only be released when it — the same goroutine — unlocks. Since it’s blocked waiting for itself, it can never proceed.

func f() {
    mu.Lock()
    defer mu.Unlock()
    f()       // deadlock — f re-enters, blocks on itself
}

This is a genuine deadlock, not an error. There’s no runtime “you already hold this lock” check — the goroutine just parks forever.

The contrast is with languages like Java where intrinsic locks are reentrant. Go made a deliberate design choice: non-reentrant mutexes are simpler and catch lock-ordering bugs early. If you genuinely need re-entrant locking, the idiom is to restructure — don’t re-lock, or use separate levels of locking.

The interview answer: locking an already-held sync.Mutex from the same goroutine blocks forever — a self-deadlock.

13. What is the main utility of context.Context in Go service applications?

Answer: Propagating cancellation signals, deadlines, and request-scoped values across API boundaries and goroutines.

context.Context is the standard way to carry the lifecycle of a request through the call chain. Its three core capabilities:

  • Cancellationcontext.WithCancel creates a cancellable context; when the cancellation function is called (or the parent is cancelled), every goroutine holding the derived context learns about it via ctx.Done(). This is how an HTTP handler’s cancellation unwinds through downstream calls.
  • Deadlinescontext.WithTimeout / context.WithDeadline attach a time bound; operations should check the deadline and abort when it passes. This prevents runaway requests.
  • Valuesctx.Value(key) carries request-scoped data (request ID, auth principal) down the call stack, avoiding global state.

The idiomatic rules: context should be the first parameter of functions that do I/O or spawn work, and it’s used to coordinate cancellation rather than for passing arbitrary configuration. Libraries like the HTTP client and database/sql all honor ctx for cancellation and timeouts.

The interview answer: context propagates cancellation, deadlines, and scoped values across goroutines and API boundaries.

14. How do you implement custom string formatting output for a struct type when using fmt.Println()?

Answer: Implement the fmt.Stringer interface by defining a String() string method.

fmt is interface-driven. When it formats a value, it checks whether the type satisfies Stringer — i.e., has a String() string method. If so, it calls it and prints the result.

type User struct { Name string }

func (u User) String() string {
    return "User(" + u.Name + ")"
}

fmt.Println(User{"alice"})   // prints: User(alice)

Implementing String() gives you control over how %v, Println, Printf with %v, and error formatting display your type.

The interview answer: define func (t Type) String() string — satisfying fmt.Stringer — and fmt will use it automatically.

15. What is the output of the following string indexing expression?

s := "hello"
fmt.Println(reflect.TypeOf(s[0]))

Answer: uint8 (a byte).

Indexing a Go string returns the raw byte at that position, typed as uint8 — the byte alias. It does not return a rune or a character.

This is a consequence of Go strings being sequences of bytes, not characters. s[0] gives the first byte of the UTF-8 encoding of "hello" — for ASCII, that byte happens to be the same numeric value as the character ('h' = 104).

The rune distinction matters for non-ASCII text: indexing a string containing "é" (two bytes in UTF-8) at position 0 gives a byte, not the full character. To iterate actual characters, use range:

for _, r := range s { ... }   // r is a rune (int32)

The interview answer: s[0] is a uint8/byte — raw bytes, not characters.

My Private Notes

Notes are auto-saved locally to this device.