Skip to content

Table of Contents

Go Standard Library & Runtime — Interview Questions

A curated set of questions covering the Go standard library, runtime internals, reflect, encoding/json, io, and time. Difficulty is marked per question: 🟢 junior · 🟡 mid · 🔴 senior.

Table of Contents

Runtime & Scheduler

🟢 What is a goroutine and how does it differ from an OS thread? A goroutine is a lightweight, user-space thread of execution managed by the Go runtime rather than the operating system. Goroutines start with a small stack (currently 8 KB) that grows and shrinks dynamically, whereas OS threads typically reserve 1–8 MB. The runtime multiplexes many goroutines onto a small number of OS threads using the G-M-P scheduler, so creating hundreds of thousands of goroutines is cheap. Context switches between goroutines happen in user space and are far cheaper than kernel thread switches. You create one with the `go` keyword.
go func() { fmt.Println("runs concurrently") }()
🟡 Explain the G-M-P scheduling model in the Go runtime. The Go scheduler uses three core entities: **G** (goroutine), **M** (machine, an OS thread), and **P** (processor, a scheduling context that holds a run queue). An M must hold a P to execute Go code, and the number of Ps is controlled by `GOMAXPROCS` (defaulting to the number of CPUs). Each P has a local run queue of runnable goroutines, plus there is a global run queue; this design reduces lock contention. When a P's local queue is empty it performs **work stealing** from other Ps. When a goroutine makes a blocking syscall, the M can detach from its P so another M can pick up the P and keep running other goroutines.
🟡 What is GOMAXPROCS and when would you change it? `GOMAXPROCS` sets the maximum number of Ps, i.e. the number of OS threads that can execute Go code simultaneously. Since Go 1.5 it defaults to `runtime.NumCPU()`. You rarely need to change it, but you might lower it in a container with a CPU quota (older Go versions weren't cgroup-aware, so people used libraries like `automaxprocs`; Go 1.25+ reads the cgroup CPU limit by default). Raising it above the core count generally hurts due to context-switching overhead. It can be set via the env var or `runtime.GOMAXPROCS(n)`.
runtime.GOMAXPROCS(4)
🔴 How does Go's garbage collector work and what is the role of GOGC? Go uses a concurrent, tri-color mark-and-sweep collector with a write barrier, optimized for low latency rather than peak throughput. Most of the marking runs concurrently with your program, with brief stop-the-world pauses (typically sub-millisecond) at the start and end of a cycle. `GOGC` (default 100) controls the heap growth target: a value of 100 means the GC triggers when the live heap doubles since the last collection. Lowering `GOGC` collects more often (less memory, more CPU); raising it trades memory for fewer collections. Since Go 1.19 you can also set a soft memory limit with `GOMEMLIMIT`, which is useful in containers to avoid OOM kills.
🟡 What does runtime.Gosched do, and how is it different from runtime.Goexit? `runtime.Gosched()` yields the processor, allowing other goroutines to run, and puts the current goroutine back on the run queue to be resumed later — it does not suspend the current goroutine permanently. `runtime.Goexit()` terminates the calling goroutine entirely; any deferred calls run before it exits, but the function never returns to its caller. Goexit does not affect other goroutines. In practice you almost never need either: the scheduler is preemptive (since Go 1.14 it uses asynchronous preemption based on signals), so manual yielding is rarely required.
🔴 What is goroutine preemption and how did it change in Go 1.14? Before Go 1.14, the scheduler used cooperative preemption: a goroutine could only be preempted at function-call boundaries where the runtime inserted safe-point checks. This meant a tight loop with no function calls (e.g. `for {}`) could monopolize a P and starve other goroutines or stall garbage collection. Go 1.14 introduced **asynchronous preemption**: the runtime sends a signal (SIGURG on Unix) to a running goroutine, and the signal handler safely preempts it even mid-loop. This made tail latency more predictable and eliminated a class of "stuck program" bugs.

encoding/json

🟢 Why are only exported struct fields marshaled by encoding/json? `encoding/json` uses reflection to read and write struct fields, and reflection can only access exported (capitalized) fields of a struct from another package. Unexported fields are invisible to the marshaler and unmarshaler, so they are silently skipped — they are neither emitted on marshal nor populated on unmarshal. This is a frequent source of bugs where a lowercased field never appears in the JSON output. If you need a different external name, use a struct tag.
type User struct {
    Name  string `json:"name"`
    email string // unexported: ignored by json
}
🟢 What do struct tags control in encoding/json, including omitempty? The `json:"..."` struct tag controls the JSON key name and options. The first comma-separated item is the field name (`json:"user_id"`), and `-` means "never marshal/unmarshal this field". The `omitempty` option omits the field from output when it holds its zero value (0, "", nil, empty slice/map, false). Note that `omitempty` does **not** work for zero-valued structs, because a struct has no "empty" definition. You can also use `json:",string"` to encode numeric/bool fields as JSON strings.
type Item struct {
    ID    int    `json:"id"`
    Note  string `json:"note,omitempty"`
    Debug string `json:"-"`
}
🟡 When you unmarshal arbitrary JSON into an interface{}, what Go types do you get? Unmarshaling into an `interface{}` (or `any`) produces a fixed mapping: JSON objects become `map[string]interface{}`, arrays become `[]interface{}`, strings become `string`, booleans become `bool`, `null` becomes `nil`, and — importantly — **all numbers become `float64`**, regardless of whether they look like integers. This float64 behavior catches people off guard: large integer IDs can lose precision. To preserve integer fidelity, use `json.Decoder` with `UseNumber()`, which decodes numbers into `json.Number` (a string you can convert exactly).
var v any
json.Unmarshal([]byte(`{"n": 42}`), &v)
// v.(map[string]any)["n"] is float64(42)
🟡 What is json.RawMessage and when is it useful? `json.RawMessage` is a `[]byte` type that implements `Marshaler` and `Unmarshaler` by storing the raw, un-decoded JSON bytes verbatim. On unmarshal it captures a sub-document without parsing it; on marshal it injects pre-encoded JSON as-is. This is useful for deferred or conditional decoding — e.g. you read a `"type"` discriminator field first, then decode the payload into the right concrete type. It also avoids re-encoding when you're just passing JSON through.
type Envelope struct {
    Type string          `json:"type"`
    Data json.RawMessage `json:"data"`
}
🟡 How do you stream large JSON instead of loading it all into memory? Use `json.NewDecoder(r)` to read from an `io.Reader` and `json.NewEncoder(w)` to write to an `io.Writer`, rather than `Unmarshal`/`Marshal` which require the whole document in a byte slice. The decoder can also read a stream of values one at a time, and you can call `dec.Token()` and `dec.More()` to walk a large array element by element without buffering the entire payload. This keeps memory bounded for big HTTP bodies or files.
dec := json.NewDecoder(resp.Body)
dec.Token() // consume opening '['
for dec.More() {
    var item Record
    dec.Decode(&item)
}
🔴 How do you customize JSON encoding for a type, and what's the difference between value and pointer receivers here? Implement `json.Marshaler` (`MarshalJSON() ([]byte, error)`) and/or `json.Unmarshaler` (`UnmarshalJSON([]byte) error`). The receiver type matters: `UnmarshalJSON` must use a pointer receiver because it mutates the value, and the field must be addressable for it to be called. For `MarshalJSON`, if you define it on a value receiver it works for both values and pointers; on a pointer receiver it is only used when the value is addressable/a pointer. A common pitfall is causing infinite recursion by calling `json.Marshal(t)` on the same type inside `MarshalJSON` — define a type alias to strip the method set first.
func (d Date) MarshalJSON() ([]byte, error) {
    return []byte(strconv.Quote(time.Time(d).Format("2006-01-02"))), nil
}

reflect

🟡 What is the difference between reflect.Type and reflect.Value? `reflect.Type` describes the static type information of a value — its kind, name, fields, methods, and so on — obtained via `reflect.TypeOf(x)`. `reflect.Value` holds the actual runtime value and lets you read and (sometimes) modify it, obtained via `reflect.ValueOf(x)`. You inspect structure through Type and read/write data through Value; a Value also carries its Type, accessible via `v.Type()`. The two together implement the duality the runtime needs: "what is it" versus "what does it contain".
🔴 Explain settability in reflect. Why does reflect.ValueOf(x).Set... often panic? A `reflect.Value` is **settable** only if it is addressable and was not obtained from an unexported field. `reflect.ValueOf(x)` copies `x`, so the resulting Value is not addressable and calling `Set` panics. To mutate the original you must pass a pointer and dereference with `Elem()`: `reflect.ValueOf(&x).Elem()` is settable. Check with `v.CanSet()` before setting. This mirrors normal Go semantics — you can't assign through a copy, only through something addressable.
x := 10
v := reflect.ValueOf(&x).Elem()
v.SetInt(20) // CanSet() == true; x is now 20
🔴 What are the performance costs of using reflect, and how do you mitigate them? Reflection is significantly slower than direct code because it defeats inlining, allocates (values get boxed into `interface{}`), and does runtime type checks and field lookups by name/index on every call. It also moves errors from compile time to run time, hurting safety. Mitigations include caching reflected `Type`/field metadata outside hot loops, generating code (e.g. with `go generate`) instead of reflecting at runtime, and using generics (Go 1.18+) which often eliminate the need for reflection entirely. Libraries like `easyjson` exist precisely to replace reflection-based JSON with generated code.

io & bufio

🟢 Describe the io.Reader and io.Writer interfaces and why they're so central. `io.Reader` has a single method `Read(p []byte) (n int, err error)` and `io.Writer` has `Write(p []byte) (n int, err error)`. Their power comes from being tiny, uniform abstractions that countless types implement: files, network connections, buffers, HTTP bodies, compressors, encoders. Because everything speaks the same interface, you can compose and chain them freely — wrap a file in a gzip reader in a buffered reader — without any type knowing about the others. This is a textbook example of small interfaces enabling composition.
🟢 What does io.Copy do and why is it efficient? `io.Copy(dst, src)` reads from `src` and writes to `dst` until EOF or an error, returning the number of bytes copied. It avoids allocating a large buffer for the whole payload — it streams through a small internal buffer (32 KB) in a loop, so memory stays bounded regardless of size. It also checks whether `src` implements `WriterTo` or `dst` implements `ReaderFrom`, taking a fast path (such as the zero-copy `sendfile` syscall) when available. It is the idiomatic way to pipe data between any reader and writer.
n, err := io.Copy(os.Stdout, resp.Body)
🟡 When and why would you use bufio.Reader/Writer? `bufio` wraps an underlying `io.Reader`/`io.Writer` with an in-memory buffer to reduce the number of system calls. Without buffering, many small `Read`/`Write` calls each become a syscall, which is expensive; `bufio.Writer` accumulates writes and flushes in larger chunks. It also adds convenience methods like `ReadString`, `ReadLine`, and `Scanner`-style line reading. Remember to call `Flush()` on a `bufio.Writer` (often via defer) or buffered data may never reach the destination.
w := bufio.NewWriter(file)
defer w.Flush()
fmt.Fprintln(w, "buffered line")
🔴 What's the difference between io.EOF and an unexpected EOF, and how should Read callers handle errors? `io.EOF` is the normal, expected signal that a stream has ended with no more data; it is not a failure. `io.ErrUnexpectedEOF` means the stream ended in the middle of a structure that needed more bytes (e.g. a partial fixed-size read), which is an error. A correct `Read` caller must process the returned `n` bytes **before** checking the error, because `Read` may return `n > 0` together with a non-nil error including `io.EOF`. Using helpers like `io.ReadFull` or `bufio.Scanner` removes much of this subtlety.

time

🟡 What is the monotonic clock in Go's time package and why does it matter? Since Go 1.9, `time.Now()` returns a `Time` containing both a wall-clock reading and a **monotonic clock** reading. Wall-clock time can jump backward or forward due to NTP adjustments or manual changes, which would make duration measurements wrong. When you compute `time.Since(start)` or subtract two Times from the same machine, Go uses the monotonic component so elapsed-time measurements are always correct and never negative. Operations that strip the monotonic reading (like marshaling, or `t.Round`) fall back to wall-clock arithmetic, so prefer comparing Times directly for timing.
🔴 Why must you Stop a time.Ticker, and what's the leak with time.After in a select loop? A `time.Ticker` holds a runtime timer that keeps firing on its channel until you call `Stop()`; if you drop the ticker without stopping it, the underlying timer isn't garbage collected and leaks. So always `defer ticker.Stop()`. Similarly, `time.After(d)` creates a brand-new `Timer` each time it's evaluated; using it inside a `for { select { ... } }` loop allocates a fresh timer on every iteration, and each one stays alive until it fires (up to `d` later), which leaks under high iteration counts. The fix is to create one `time.NewTimer` outside the loop and `Reset` it, or in Go 1.23+ rely on the GC-collectable timer behavior.
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for range ticker.C {
    // work
}
🟡 How do you implement a timeout for a blocking operation using context and time? The idiomatic approach is `context.WithTimeout`, which gives you a context that is canceled after the duration; pass it into context-aware APIs and always defer the returned `cancel` to release resources. For a manual select-based timeout you can race the operation's channel against `time.After`, but prefer context because it propagates cancellation down the call chain and the timer is managed for you.
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
result, err := doRequest(ctx)
🟢 What is the reference time layout in Go's time formatting, and why is it unusual? Go does not use `%Y-%m-%d` style format codes. Instead it uses a specific **reference time**: `Mon Jan 2 15:04:05 MST 2006` (which is `01/02 03:04:05PM '06 -0700`, a mnemonic 1-2-3-4-5-6-7 sequence). You express the desired output by writing that exact reference time in the format you want. For example `t.Format("2006-01-02")` yields an ISO date. Beginners often write `"YYYY-MM-DD"` which silently produces literal "YYYY-MM-DD" output, a classic mistake.
fmt.Println(time.Now().Format("2006-01-02 15:04:05"))