Your outcome today
See the model.
Then run the interview.
In about 25 minutes, learn the reasoning behind five struct/interface questions and five concurrency questions. During the interview, reveal the answer only after the candidate commits to a prediction.
Fast route
One pass before the call
Ask “What happens, and why?” before asking “How would you fix it?” Prediction exposes the candidate’s model; the fix exposes engineering judgment.
Part 1 · Visual foundation
Two maps explain all ten questions
Don’t memorize ten isolated answers. Use one map for type behavior and one for coordination.
Map A — concrete value → method set → interface
Counterdata value
Value()satisfies
*Counterpointer value
Value() + Add()possible
T has methods declared on T. *T has methods declared on both T and *T. An addressable value may get call-site shorthand, but that does not change its method set.
Authority: Go specification — method sets
Map B — an interface value is a pair
Use this conceptual pair to reason about typed nils.
This interface is nil.
This interface is not nil. It contains a concrete type even though that type’s value is a nil pointer.
Authority: Go FAQ — nil error values
Map C — give each coordination tool one job
Visibility needs an ordering path
UnlockLockNo synchronization arrow means no visibility guarantee. “It probably finishes first” is not a synchronization mechanism.
Authority: The Go Memory Model and package sync
Transfer work or ownership → channel. Guard shared state → mutex. Wait for completion → WaitGroup. Stop an operation tree → context. These tools are complementary.
Part 2 · Retrieval practice
The ten interview questions
Say your own answer aloud before opening each reveal. A four-minute timer is included because concise reasoning is part of the signal.
Five on structs & interfaces
Pointer receiver vs method set
Which three lines compile? Explain why the first call and the interface assignment follow different rules.
type Counter struct{ n int }
func (Counter) Value() int { return 0 }
func (*Counter) Add() {}
type Incrementer interface{ Add() }
var c Counter
c.Add() // A
var i Incrementer = c // B
var j Incrementer = &c // C
Reveal model answer
A and C compile; B does not. Because c is addressable, c.Add() is shorthand for (&c).Add(). But interface satisfaction uses method sets: Counter has only Value; *Counter has Value and Add. Assignment does not auto-take the address.
Probe: Why would a mutating method normally use *Counter? What if the receiver contains a mutex?
Struct copy is not deep copy
After these mutations, what does a contain? How would you make b independent?
type Batch struct {
IDs []int
Meta map[string]string
}
a := Batch{
IDs: []int{1, 2},
Meta: map[string]string{"owner": "ana"},
}
b := a
b.IDs[0] = 9
b.Meta["owner"] = "bob"
Reveal model answer
a.IDs is [9 2] and a.Meta["owner"] is "bob". Assignment copies the struct fields, but a slice field is a descriptor referring to an underlying array and a map refers to runtime-managed map data. Copy each referenced structure explicitly when independent ownership is required.
b := a
b.IDs = append([]int(nil), a.IDs...)
b.Meta = make(map[string]string, len(a.Meta))
for k, v := range a.Meta { b.Meta[k] = v }
Probe: If b.IDs = append(b.IDs, 3), must a observe it? Answer: it depends on capacity and allocation; element aliasing and slice length are separate issues.
Embedding and ambiguity
Does C satisfy Pinger? Is struct embedding inheritance?
type A struct{}
func (A) Ping() {}
type B struct{}
func (B) Ping() {}
type C struct { A; B }
type Pinger interface{ Ping() }
var _ Pinger = C{}
Reveal model answer
It does not compile. Both embedded fields promote a Ping at the same depth, so C.Ping is ambiguous and C has no unique promoted method satisfying Pinger. Calls can be qualified as c.A.Ping(), or C can declare its own Ping.
Embedding is composition with field/method promotion, not subtype inheritance. A promoted call still uses the embedded field as receiver; there is no superclass-style dynamic dispatch.
Probe: How would func (C) Ping() {} change the result? It resolves the selector and satisfies the interface.
Spec: embedded fields and promoted methods · Effective Go: embedding
The typed-nil interface
What does the comparison print, and what exactly is stored in the returned interface?
type AppError struct{}
func (*AppError) Error() string { return "failed" }
func load() error {
var e *AppError
return e
}
fmt.Println(load() == nil)
Reveal model answer
It prints false. The returned error holds dynamic type *AppError and dynamic value nil. An interface compares equal to nil only when neither a dynamic type nor value is present. Return an explicit nil on the success path.
Probe: Can a method be invoked through this interface? Yes; it receives a nil pointer. Whether it panics depends on the method body.
Design the smallest boundary
An encoder only calls Write. Should its parameter be *bytes.Buffer, a custom Write/Flush/Close interface, or io.Writer? Why?
func Encode(dst ???, record Record) error
Reveal model answer
Use io.Writer if writing bytes is the whole requirement. It is the smallest established contract, accepts buffers, files, network connections, hashes, and test doubles, and avoids coupling to one implementation. Do not require Flush or Close unless this function uses or owns those lifecycle operations.
Good Go interfaces usually describe behavior needed by the consumer. Define a new interface near the consuming code only when no existing contract expresses the need.
Probe: Would you return an interface from a constructor? Listen for nuance: concrete returns preserve capability; interface returns can be right when abstraction is intentional.
Five on concurrency
Starting is not joining
What can this print? Is it correct Go? What synchronization guarantee is missing?
var n int
go func() {
n = 1
}()
fmt.Println(n)
Reveal model answer
It may appear to print 0 or 1, but the program has a data race and no result is guaranteed. The go statement is ordered before the new goroutine starts; it does not order that goroutine’s completion before the print. The process can also finish without waiting for detached work.
Use a channel or WaitGroup to establish completion. For example, write n, close done, receive from done, then read n.
Probe: Would time.Sleep make it correct? No. Timing is not synchronization.
Buffering and backpressure
What happens here? Would changing capacity to two be a robust fix?
ch := make(chan int, 1)
ch <- 1
ch <- 2
fmt.Println(<-ch)
Reveal model answer
The first send fills the single slot. The second send blocks because the buffer is full and no other goroutine can receive, so the runtime reports a deadlock. Capacity two happens to let this snippet proceed, but it encodes an assumed burst size rather than fixing the producer/consumer design.
An unbuffered send waits for a matching receive. A buffered send waits only when the buffer is full. Buffers decouple timing up to a bound and provide backpressure at that bound.
Probe: When is a capacity-one channel meaningful? Examples include a latest pending signal, a one-token semaphore, or deliberately bounded handoff.
Who closes a shared output?
Several workers send to one result channel. Who should close it, and how can the consumer safely range over it?
for i := 0; i < workers; i++ {
wg.Add(1)
go worker(jobs, results, &wg)
}
for result := range results {
use(result)
}
Reveal model answer
No individual worker can know that all sends are finished. A coordinator waits for every worker, then closes the shared output while the consumer is already able to drain it.
go func() {
wg.Wait()
close(results)
}()
for result := range results { use(result) }
Closing expresses “no more values,” not “please stop.” A receive from a closed channel returns remaining buffered values, then the zero value with ok == false. Sending to or closing an already-closed channel panics. A nil channel blocks forever, which can intentionally disable a select case.
close(results), or asks the receiver to close it.Probe: Why not call wg.Wait() before the range in the same goroutine? Workers may block sending while nobody drains results.
Go blog: pipelines and cancellation · Package sync.WaitGroup
The copied mutex
The method locks before writing. Why is it still unsafe?
type Cache struct {
mu sync.Mutex
m map[string]string
}
func (c Cache) Put(k, v string) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[k] = v
}
Reveal model answer
The value receiver copies Cache, including its mutex. The copied map field still refers to shared map data, but concurrent calls can lock different mutex copies, so they do not exclude each other. Use a pointer receiver, keep the mutex with the state it guards, and do not copy a mutex after first use.
func (c *Cache) Put(k, v string) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[k] = v
}
Every access participating in the invariant—including reads—must follow the same locking rule. Verify exercised paths with go test -race ./....
defer cost or claims concurrent map reads are always safe.Probe: When would you choose RWMutex? Only after a clear read-heavy need; it adds complexity and is not automatically faster.
Cancellation and goroutine leaks
After first returns, what happens to the producer? Redesign its lifetime.
func produce(out chan<- int) {
for i := 0; ; i++ {
out <- expensive(i)
}
}
func first() int {
out := make(chan int)
go produce(out)
return <-out
}
Reveal model answer
After the first receive, no receiver remains. The producer eventually blocks forever on its next send, retaining its stack and anything it references. Give the operation a cancellation path and make the sending goroutine own closure of its output.
func produce(ctx context.Context) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := 0; ; i++ {
v := expensive(i)
select {
case out <- v:
case <-ctx.Done():
return
}
}
}()
return out
}
func first() int {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
return <-produce(ctx)
}
If expensive can itself block, it also needs to accept and observe cancellation. Cancellation must reach every blocking boundary.
Probe: Is closing out a cancellation mechanism for the producer? No; a receiver should not close a sender-owned data channel, and a later send would panic.
Part 3 · Consistent evaluation
Score reasoning, not vocabulary
| Points | Observable evidence | Interviewer action |
|---|---|---|
| 0 | Cannot predict behavior, invents a rule, or proposes an unsafe fix. | Give one small hint, then move on. |
| 1 | Gets the result or rule, but explanation is shallow or the fix misses an edge case. | Use the listed probe once. |
| 2 | Predicts correctly, explains the underlying model, and gives a safe, proportionate fix. | Ask for trade-offs; avoid trivia escalation. |
Interpretation for this set
- 16–20: strong intermediate signal
- 12–15: solid, with focused gaps
- 8–11: material gaps for this level
- 0–7: foundations need strengthening
Treat bands as evidence, not an automatic hiring decision. Weight communication, experience, and the role’s actual work.
Three cross-question signals
- Ownership: who may mutate, close, or cancel?
- Lifetime: what starts work, and what guarantees it stops?
- Ordering: which synchronization edge makes a read safe?
A candidate who repeatedly asks these questions is usually reasoning from durable principles.
Accept different safe designs. Channels and mutexes are both valid when they make ownership and invariants clear. Score the reasoning and trade-off, not whether the candidate picked your favorite primitive.
Primary reading
Keep the authority close
Primary source
The Go Programming Language Specification, especially struct types, interface types, and method sets. Use it when a compile-time claim is disputed.
Concurrency companion
The Go Memory Model. Read the advice, goroutine creation, channel communication, and locks sections; formal notation can wait.