1. How to answer Go questions: model → consequences → fix
In interviews, they often test not your memory and not your typing speed, but your ability to keep the code execution model in your head. In Go this is especially noticeable: the language intentionally does not hide details (we check errors explicitly, we assemble concurrency manually, interfaces are not magic). That is why a good answer usually sounds like a short story: “here is what is stored in the value,” “here is why it leads to this behavior,” “here is how to make it safe.”
Imagine a typical situation: they show you a piece of code and ask “what will it print?” or “why does it crash?” Your best friend is calm reading left to right and verbalizing invariants: what can be nil, who owns the slice buffer, who closes the channel, what exactly is inside an interface. If you learn to break questions down into these “building blocks,” half of the interview turns into a predictable walk instead of a “guess what the author meant” quest.
Mini example: “verbalize the model” (we will return to it throughout the lecture):
package main
import "fmt"
func main() {
var s []int
fmt.Println(s == nil, len(s)) // true 0
}
Here you do not “remember a fact,” you explain the model: var s []int gives a nil slice, len(nil) is legal and equals zero, comparison with nil is legal.
2. Errors in Go: why they are “values” and how interviewers ask about them
Errors are interviewers’ favorite topic because they immediately show your thinking style. In Go you do not “throw” an error; you return it. And that is not an accident but a principle: an error is a normal value that you can compare, wrap, store inside structs, and analyze programmatically.
Questions usually revolve around four things: why error is an interface, how fmt.Errorf("%v", err) differs from fmt.Errorf("%w", err), when to use sentinel errors vs typed errors, and how to build messages so they are clear to the user and diagnosable for the developer.
error as an interface, and “errors are values”
In Go, error is an interface with a single method Error() string. The point is that an error can be not only a string but also structured data: for example, os.PathError stores the operation and the path, net.OpError stores details of a network operation, and all of that can be analyzed rather than parsed from a string.
Mini example: “error as a struct,” in the style of our task app (e.g., input validation):
package app
type ValidationError struct {
Field string
Msg string
}
func (e ValidationError) Error() string {
return e.Field + ": " + e.Msg
}
In an interview, it is important to say out loud: “the string is for humans; the fields are for code.” Interviewers usually nod, because that is exactly the Go way.
Wrapping: %w and the chain of causes
A very common question: “what is the difference between %v and %w in fmt.Errorf?” The answer is not “it’s a different letter,” but that %w preserves the original error as a cause in the error chain, which means you can later test it via errors.Is or extract a type via errors.As.
Here is a micro example that resembles our task storage layer (e.g., reading from a file):
package storage
import "fmt"
func loadTasks(path string) error {
// suppose something went wrong here
err := fmt.Errorf("permission denied")
return fmt.Errorf("load tasks from %s: %w", path, err)
}
If we used %v, the “cause” would become just text, and code above would not be able to reliably distinguish a permission problem from not found.
Conceptually, the chain looks like this:
flowchart TD
A["upper layer: 'load tasks from X'"] -->|wrap %w| B["lower layer: 'permission denied'"]
B --> C[deeper: syscall/os/net errors]
And this is not theory for theory’s sake: it is a practical reason why Go code loves to “wrap” errors, pushing context upward.
errors.Is and errors.As: what and when
errors.Is(err, target) answers the question “is there a cause of this class anywhere in the chain?” errors.As(err, &target) answers the question “can we extract an error of a specific type from the chain and read its fields?” This directly continues the idea that errors are structured values, not text.
Mini example in the style of “we have a domain ‘not found’ error”:
package domain
import "errors"
var ErrNotFound = errors.New("not found")
func isNotFound(err error) bool {
return errors.Is(err, ErrNotFound)
}
In an interview, it is useful to explicitly say: comparing errors by their string is a weak model, because strings change, get localized, and are not a contract.
Sentinel errors vs typed errors: how to choose
An interviewer may ask: “When should you do var ErrX = errors.New("..."), and when should you do type XError struct {...}?” A good answer uses a criterion: if the code only needs a “class of cause” (e.g., not found), a sentinel is usually fine. If the code needs context (which field is invalid, which id, which path), a typed error is better.
Imagine our HTTP endpoint /api/v1/tasks/{id}. For 404 we only need a “class of cause.” For 400 validation we need fields so we can populate fields in the error envelope.
A short example of a typed validation error “our way”:
package domain
type ValidationError struct {
Fields map[string]string
}
func (e ValidationError) Error() string {
return "validation failed"
}
In an interview you can say: “the error text is stable and short, while the details in Fields are used at the boundary (CLI/HTTP) for UX.” That sounds businesslike and is exactly how production applications are commonly built.
3. Interfaces: method set, and the trap interface != nil
Interfaces in Go are deceptively simple: “a set of methods.” But interviews test details, because that is where real bugs most often live. Typical questions: how a type implements an interface (spoiler: implicitly), what a method set is and why T and *T behave differently, and finally the genre classic: “why is err != nil when there is nil inside?”
If you explain this confidently, you immediately look like someone who has written production Go code rather than just read notes.
Where to declare an interface
For interviews, it is useful to remember: a good interface is small and is declared “where it is used.” In our application this looked like Storage in the app layer: the service does not care whether it is a file or memory; it needs a contract.
Mini example (simplified):
package app
import "context"
type Storage interface {
Get(ctx context.Context, id int) (Task, error)
}
If an interviewer asks “why an interface?”, you answer: “to separate the core from adapters and test without a real filesystem.” That is an engineer-level answer, not “well, that’s how it’s done.”
Method set: why *T “sees” more methods
A favorite question: “why doesn’t var _ IFace = T{} compile, but var _ IFace = &T{} does?” Answer: the method set. Methods with a pointer receiver belong to *T, not T. This is especially obvious when a method mutates state or when the type is heavy and copying is expensive.
A related real case: if your Storage has methods with pointer receivers, then only *FileStorage implements Storage, not FileStorage.
Mini example:
package main
type Saver interface{ Save() error }
type FileStore struct{}
func (fs *FileStore) Save() error { return nil }
func main() {
var _ Saver = &FileStore{} // ok
// var _ Saver = FileStore{} // will not compile
}
The trap interface != nil
This is a classic. The interviewer shows code where var p *T = nil; return p as IFace, and asks why iface != nil.
The model is: an interface value is a pair (dynamic type, dynamic value). If the type is known (*User), then the interface is not nil even if the value inside is a nil pointer.
Mini example (short and painful):
package main
import (
"fmt"
)
type Namer interface{ Name() string }
type User struct{ N string }
func (u *User) Name() string { return u.N }
func main() {
var u *User = nil
var n Namer = u
fmt.Println(u == nil) // true
fmt.Println(n == nil) // false
}
In an interview answer, it is important to add a practical consequence: “that is why I try to return nil from functions as a truly nil interface, not as a typed nil.” That shows you not only know the trap, but can avoid it.
4. Slices: len/cap, append, aliasing, and surprises
Slices are the interviewers’ second favorite topic (after errors), because they reveal whether you understand memory and ownership. Common questions: how a slice differs from an array, what a backing array is, why append can mutate the original array, what nil slice vs empty slice means, and how to properly “delete an element.”
If you answer this confidently, you not only pass the interview, you also save yourself months of debugging later.
A slice as a “window” into an array: pointer + len + cap
A good mental model: a slice is not an array, but a small header struct (pointer to buffer, length, capacity). That is why slices are easy to copy as values, but may still reference the same backing array.
A handy diagram:
flowchart LR
S[slice header] --> P[ptr -> backing array]
S --> L[len]
S --> C[cap]
This is exactly why “a copy of a slice” does not mean “a copy of data.”
nil vs empty: similar, but not the same
In interviews they sometimes ask: “are a nil slice and []int{} the same?” They have the same len (0), but a nil slice compares equal to nil, while an empty literal does not.
Mini example:
package main
import "fmt"
func main() {
var a []int
b := []int{}
fmt.Println(a == nil) // true
fmt.Println(b == nil) // false
}
Do not overinflate the philosophy: in most applied code you check len(s) == 0, not s == nil. But remembering the difference is useful, especially for JSON output and API contracts.
append: why you must keep the result
Interviews love the question: “why do you need to assign the result of append(s, x)?” Because append may allocate a new buffer, and then the returned value is a new slice header that you must keep.
Mini example:
package main
import "fmt"
func main() {
s := make([]int, 0, 1)
s = append(s, 1)
s2 := append(s, 2)
fmt.Println(s, s2) // [1] [1 2]
}
That is not even the trickiest part. The trickiest part starts when we have a subslice.
Aliasing: subslices and unexpected mutations
A classic bug: we take sub := s[:1], then do append to sub, and suddenly s changes. Why? Because sub’s cap can be larger than len, and append writes into the same backing array.
Mini example:
package main
import "fmt"
func main() {
s := []int{10, 20, 30}
sub := s[:1] // len=1, cap=3
sub = append(sub, 99) // overwrites s[1]
fmt.Println(s) // [10 99 30]
}
In interviews, it is good form to mention the “ownership boundary”: if you want a subslice to become independent, you copy into a new buffer or use the full slice expression s[a:b:c] to limit cap. That sounds like “I know how to fix it,” not just “I know it hurts.”
Deleting elements and clearing the tail
Deletion is a favorite practical task: “delete element i.” And it is important not only to “shift,” but also to remember memory. In newer Go versions, the standard functions in the slices package (e.g., slices.Delete) additionally “clear the tail” via clear, so you do not retain references and create hidden memory leaks.
But interviewers often test an even more basic thing: you understand that many slice operations return a new slice, and you must keep it.
Mini example: “save the result correctly”:
package main
import (
"fmt"
"slices"
)
func main() {
s := []string{"a", "b", "c"}
s = slices.Delete(s, 1, 2)
fmt.Println(s) // [a c]
}
If you say “that is why I always assign the result of Delete/Compact back,” that is exactly the kind of “quality signal” interviewers like to hear.
5. Concurrency: goroutines, channels, and data races
Concurrency in Go is where interviewers look not only at knowledge, but also at discipline. Because “it sometimes works” is not success; it is the beginning of a detective story. Typical questions: how concurrency differs from parallelism, how WaitGroup works, why channels exist, what deadlock is, what a data race is, and how to cancel work via context.
In a good answer, you show that concurrency for you is a completion protocol and resource ownership, not “let’s parallelize everything, it will be fast (probably).”
The Go approach: “share memory by communicating”
Go has a strong philosophy: instead of giving all goroutines access to one struct and protecting it with locks, you can build a data flow through channels. This is not always required, but as an idea it is very characteristic of Go.
If an interviewer asks “channels or mutex?”, a good answer is not religious (“channels are better!”), but practical: “channels are convenient for passing jobs and results; mutex is convenient for protecting shared state; the main thing is that the protocol has an end.”
WaitGroup: Add/Done/Wait and common traps
WaitGroup is often asked at the “why does it sometimes crash/hang” level. The most typical mistake is calling Add(1) inside a goroutine: then main can reach Wait() before Add, and you get a protocol race in the waiting logic.
Mini example: “the right way”:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("worker done") // worker done
}()
wg.Wait()
}
If you state the rule “Add before starting a goroutine, Done is mandatory, preferably via defer,” that is usually counted as “understands it.”
Deadlock: “no one to receive”
The most minimal question: “why will this hang?” Because an unbuffered channel requires a receiver at the same time.
package main
func main() {
ch := make(chan int)
ch <- 1 // deadlock: nobody reads
}
In interviews it is good to add: “a buffered channel removes some blocking, but does not eliminate the need for a close/stop protocol.”
Data race: why “it seems to work” is a red flag
A data race is when two goroutines access the same data, and at least one access is a write, without synchronization. And an important point: this is not “sometimes a bug,” it is a model defect. If asked about go test -race, you explain that it is a tool that helps find races, but it is better to design so state is protected explicitly.
Mini example: “bad” (do not run in prod, but fine for an interview):
package main
import "fmt"
func main() {
x := 0
go func() { x++ }()
go func() { x++ }()
fmt.Println(x) // nondeterministic
}
The right fix depends on the task: mutex, channel, atomic. But at interview level it is enough to say: “you cannot do that; you need synchronization or you must not share state.”
Tying it back to an application: parallel task import
Imagine our task tracker can import tasks from multiple files. An interviewer may ask: “how would you speed up the import?” A good answer: “I would do bounded concurrency so I do not eat all memory, and I would have a cancel-on-first-error protocol.”
A tiny sketch: “workers read jobs from a channel”:
package main
import "sync"
func worker(jobs <-chan string, wg *sync.WaitGroup) {
defer wg.Done()
for range jobs {
// read file, parse, save
}
}
Even a snippet like this, if you talk around it about “who closes the channel” (the producer), “how we stop” (context/cancel), and “how we collect errors” (an error channel or errors.Join), already demonstrates maturity.
Combo questions: when topics collide
Interviewers like situations where two models collide at once: interface + nil, slice + aliasing, errors + interfaces, concurrency + map. They are not checking breadth of knowledge, but whether you stay calm when the world is a bit more complex than one paragraph.
One classic example is a typed error inside the error interface and extracting it with errors.As. The idea: error is an interface, so you can put different error types into it, and then pull out the struct you need. This is exactly what the “errors as values” materials mean: we can “construct and deconstruct” an error using normal language tools.
Another example is “map + concurrency.” Even if you do not remember the exact runtime wording, it is important to say: “a regular map cannot be read/written concurrently without synchronization.” After that you can propose protecting it with sync.Mutex, or using ownership via a single goroutine and a command channel. That fits the “share memory by communicating” idea.
A third example is “slice and concurrency”: if you do append to the same slice from multiple goroutines without synchronization, it is a double problem. First, it is a data race on the slice header (len/cap), and second, there can be a backing array reallocation. In an interview it is enough to say: “you cannot do that; either protect with a mutex, or have each worker write into its own buffer and then merge.”
6. Typical mistakes in Go interviews
Mistake #1: compare errors by their string and hope “it’s the same anyway.”
Strings are not a reliable contract: they can change, gain context, be localized. In Go, an error is a value, and the right tools are errors.Is to check the cause and errors.As to extract typed context.
Mistake #2: “wrap” an error with %v and lose the cause.
If you do fmt.Errorf("...: %v", err), you get nice text, but you break the ability to programmatically determine the cause. %w preserves the chain, and that is the foundation of proper diagnostics in Go applications.
Mistake #3: not understand method sets and be surprised that a type “does not implement an interface.”
Often the problem is not the interface and not imports, but that the method is declared with a pointer receiver. Then the interface is implemented by *T, but not by T. This is especially easy to hit in real projects (storage, services) and is useful to verbalize as part of your answer.
Mistake #4: fall into the trap interface != nil and return typed nil.
If a function returns an interface and you return (*MyType)(nil) as an interface value, the interface becomes non-nil because of the dynamic type. This is a source of very unpleasant bugs (“err != nil, but it looks like there is no error”). In interviews, it is valued when you can not only explain it, but also say how you avoid it in code.
Mistake #5: assume append “just adds” and not think about buffer ownership.
append can reallocate memory and change the backing array, and subslices can share a single buffer. This leads to aliasing and “unexpected changes” elsewhere in the program. Being able to explain this is an almost mandatory minimum for a Go developer.
GO TO FULL VERSION