Go is the language of cloud infrastructure — Kubernetes, Docker, etcd, Terraform, and Prometheus are all written in Go. This guide covers the Go interview questions you will face at companies using Go for backend services and infrastructure.
Goroutines and the Go Scheduler
package main
import (
"fmt"
"sync"
"time"
"runtime"
)
// Goroutine: lightweight thread managed by the Go runtime.
// Go scheduler: M:N threading — multiplexes G goroutines onto M OS threads.
// Default: GOMAXPROCS = number of CPUs (can be changed at runtime).
func goroutineBasics() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) { // Capture id by value — avoid closure over loop var
defer wg.Done()
fmt.Printf("Worker %d runningn", id)
}(i)
}
wg.Wait() // Block until all goroutines finish
}
// Goroutine leak: goroutine blocked forever on channel with no sender
// Always pair goroutine creation with a way to stop it
func withTimeout(ctx context.Context, work func() error) error {
done := make(chan error, 1) // Buffered: goroutine can always send
go func() {
done <- work()
}()
select {
case err := <-done:
return err
case <-ctx.Done():
return ctx.Err()
}
}
Channels — Communication and Synchronization
package main
import (
"fmt"
"time"
)
// Channel: typed conduit between goroutines.
// Unbuffered: send blocks until receiver is ready (synchronous)
// Buffered: send blocks only when buffer is full
func channelPatterns() {
// Fan-out: one sender, multiple receivers
jobs := make(chan int, 100)
results := make(chan int, 100)
// Start 3 workers
for w := 0; w < 3; w++ {
go func() {
for job := range jobs { // Range over channel until closed
results <- job * job
}
}()
}
// Send jobs
for j := 0; j < 9; j++ {
jobs <- j
}
close(jobs) // Signal workers: no more jobs
// Collect results
for a := 0; a < 9; a++ {
fmt.Println(<-results)
}
}
// Pipeline: chain of goroutines connected by channels
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
// select: multiplex across multiple channel operations
func rateLimiter() {
requests := make(chan int, 5)
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
quit := make(chan struct{})
for {
select {
case req := <-requests:
<-ticker.C // Wait for next tick before processing
fmt.Println("Processed:", req)
case <-quit:
return
}
}
}
Interfaces and Composition
package main
import (
"fmt"
"io"
"strings"
)
// Interfaces: satisfied implicitly (structural typing, not nominal)
// Go encourages small, focused interfaces
type Reader interface { Read(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }
type ReadWriter interface { Reader; Writer } // Interface embedding
// Empty interface vs any:
// interface{} / any: holds any value; requires type assertion to use
func printType(v any) {
switch val := v.(type) { // Type switch
case int: fmt.Printf("int: %dn", val)
case string: fmt.Printf("string: %qn", val)
case []byte: fmt.Printf("bytes: %d bytesn", len(val))
default: fmt.Printf("unknown: %Tn", val)
}
}
// Practical interface design: dependency injection + testability
type Storage interface {
Get(key string) ([]byte, error)
Set(key string, val []byte) error
Delete(key string) error
}
type UserService struct {
store Storage
cache Storage
}
func NewUserService(store, cache Storage) *UserService {
return &UserService{store: store, cache: cache}
}
func (s *UserService) GetUser(id string) ([]byte, error) {
// Try cache first
if data, err := s.cache.Get(id); err == nil {
return data, nil
}
// Fall back to store
data, err := s.store.Get(id)
if err != nil {
return nil, err
}
_ = s.cache.Set(id, data) // Populate cache
return data, nil
}
// In tests: swap in an in-memory Storage implementation
type MemStore struct{ m map[string][]byte }
func (ms *MemStore) Get(k string) ([]byte, error) {
v, ok := ms.m[k]
if !ok { return nil, fmt.Errorf("not found") }
return v, nil
}
func (ms *MemStore) Set(k string, v []byte) error { ms.m[k] = v; return nil }
func (ms *MemStore) Delete(k string) error { delete(ms.m, k); return nil }
Error Handling and Defer
package main
import (
"errors"
"fmt"
)
// Sentinel errors: package-level for comparison
var ErrNotFound = errors.New("not found")
var ErrPermission = errors.New("permission denied")
// Custom error types for structured error data
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error: %s — %s", e.Field, e.Message)
}
// Error wrapping: %w for Is/As unwrapping
func fetchUser(id string) error {
err := dbQuery(id)
if err != nil {
return fmt.Errorf("fetchUser %s: %w", id, err) // Wrap with context
}
return nil
}
func handleError(err error) {
if errors.Is(err, ErrNotFound) { // Works through wrapping chains
fmt.Println("User not found")
}
var ve *ValidationError
if errors.As(err, &ve) { // Type assertion through wrapping
fmt.Println("Bad field:", ve.Field)
}
}
// defer: runs when function returns (LIFO order)
func processFile(path string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open: %w", err)
}
defer f.Close() // Always runs — no need to close in every return path
// defer for cleanup pairs well with acquire/release patterns
mu.Lock()
defer mu.Unlock() // Always unlocked, even on panic
return doWork(f)
}
Memory Model and Garbage Collection
/*
Go GC: concurrent tri-color mark-and-sweep.
Tri-color: white (unreachable), gray (reachable, children not scanned), black (done)
Concurrent: GC runs alongside user goroutines (not stop-the-world)
STW pauses: < 1ms in Go 1.17+ (just for root marking and termination)
Memory layout:
Stack: per-goroutine, grows/shrinks dynamically (starts 2KB, max 1GB)
Heap: shared; GC managed
Escape analysis: compiler decides stack vs heap allocation
go build -gcflags="-m" shows escape decisions
Reducing GC pressure:
- sync.Pool: reuse temporary objects (byte buffers, scratch space)
- Avoid excessive small allocations (use slices with pre-allocated capacity)
- Struct embedding over pointer-to-struct for small, non-interface types
*/
package main
import (
"sync"
"bytes"
)
// sync.Pool: reuse buffers across goroutines, reducing allocation
var bufPool = sync.Pool{
New: func() interface{} { return new(bytes.Buffer) },
}
func processRequest(data []byte) []byte {
buf := bufPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
bufPool.Put(buf)
}()
buf.Write(data)
// ... process ...
return buf.Bytes()
}
// Make vs New:
// make: creates slices, maps, channels (initializes internal structure)
// new: allocates zeroed memory, returns pointer
s := make([]int, 0, 100) // slice with len=0, cap=100
m := make(map[string]int) // initialized map
p := new(int) // *int pointing to zero int
Go Interview Quick Reference
| Topic | Key Points |
|---|---|
| Goroutine vs thread | Goroutines: ~2KB stack, 100k+ concurrent; threads: ~2MB, OS-scheduled |
| Channel directions | chan<- T send-only; <-chan T receive-only; enforces data flow |
| Data race detection | go test -race or go run -race — use in CI always |
| context.Context | Pass cancellation, deadline, request-scoped values; always first param |
| Mutex vs RWMutex | RWMutex: concurrent reads allowed; use when reads >> writes |
| init() functions | Run before main(), once per package; order within package is top-to-bottom |
| Panic vs error | Error: expected failure (return); panic: programmer error or unrecoverable |
| Module proxy | GOPROXY=proxy.golang.org fetches modules; GONOSUMCHECK for private |
Frequently Asked Questions
What is a goroutine and how does it differ from a thread?
A goroutine is a lightweight function managed by the Go runtime. Goroutines start with ~2KB stack (vs ~2MB for OS threads) and are multiplexed onto OS threads by the Go scheduler (M:N threading). You can run hundreds of thousands of goroutines concurrently. Threads are managed by the OS; goroutines are managed by Go runtime.
What is a channel in Go and when would you use it?
Channels are typed conduits for communication between goroutines. Unbuffered channels block the sender until a receiver is ready (synchronous handoff). Buffered channels allow sending without blocking until the buffer fills. Use channels to signal completion, distribute work (fan-out), collect results (fan-in), or implement pipelines between goroutines.
How does Go handle errors compared to exceptions?
Go returns errors as values (multiple return values: result, error). There are no exceptions for expected failures. This makes error handling explicit — callers must check errors. Use fmt.Errorf with %w to wrap errors with context while preserving the ability to unwrap with errors.Is/errors.As. Panic is reserved for programmer errors and unrecoverable states.
{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “What is a goroutine and how does it differ from a thread?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “A goroutine is a lightweight function managed by the Go runtime. Goroutines start with ~2KB stack (vs ~2MB for OS threads) and are multiplexed onto OS threads by the Go scheduler (M:N threading). You can run hundreds of thousands of goroutines concurrently. Threads are managed by the OS; goroutines are managed by Go runtime.”
}
},
{
“@type”: “Question”,
“name”: “What is a channel in Go and when would you use it?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Channels are typed conduits for communication between goroutines. Unbuffered channels block the sender until a receiver is ready (synchronous handoff). Buffered channels allow sending without blocking until the buffer fills. Use channels to signal completion, distribute work (fan-out), collect results (fan-in), or implement pipelines between goroutines.”
}
},
{
“@type”: “Question”,
“name”: “How does Go handle errors compared to exceptions?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Go returns errors as values (multiple return values: result, error). There are no exceptions for expected failures. This makes error handling explicit — callers must check errors. Use fmt.Errorf with %w to wrap errors with context while preserving the ability to unwrap with errors.Is/errors.As. Panic is reserved for programmer errors and unrecoverable states.”
}
}
]
}