Structs, Slices & Maps, Interfaces, Errors
The types and patterns that make up the bulk of everyday Go code — and a few behaviors that catch almost everyone off guard the first time. Every claim here was checked against a real Go 1.26 toolchain and the official language sources before being written down.
Structs & methods
A struct groups fields together; a method is a function
with a receiver attached to a type. The receiver can be a value (the method
gets its own copy of the struct) or a pointer (the method operates on the
original) — and which one you choose changes whether mutations inside the
method are visible to the caller.
package main
import "fmt"
type Counter struct {
count int
}
func (c Counter) IncByValue() {
c.count++
}
func (c *Counter) IncByPointer() {
c.count++
}
func main() {
c := Counter{}
c.IncByValue()
fmt.Println("after value receiver:", c.count)
c.IncByPointer()
fmt.Println("after pointer receiver:", c.count)
}
IncByValue increments a copy that vanishes when the method returns, so
the count is still 0 afterward. IncByPointer increments the
real struct through its address, so the change sticks. As a rule of thumb: use a
pointer receiver whenever a method needs to mutate the receiver, or when the struct
is large enough that copying it on every call would be wasteful.
Calling c.IncByPointer() above works even though c is a
value, not a pointer — Go automatically rewrites it to
(&c).IncByPointer() because c is addressable
(it's a local variable). That auto-rewrite doesn't happen for everything, though:
a map element, for instance, is not addressable, so calling a pointer-receiver
method directly on one fails to compile.
Struct embedding: composition over inheritance
Go does not have inheritance — it uses struct embedding instead.
When one struct contains another, the outer type automatically gets all of the inner
type's methods as its own. This is Go's composition mechanism, and it's used
everywhere in the standard library (e.g. http.Server embeds
net.Conn):
package main
import "fmt"
type Name struct {
First string
Last string
}
func (n Name) Greet() string {
return "Hello, " + n.First + "!"
}
type Employee struct {
Name // embedded — promotes all Name methods
Department string
}
func main() {
e := Employee{Name: Name{First: "Alice"}, Department: "Sales"}
fmt.Println(e.Greet()) // promoted from Name
fmt.Println(e.First) // promoted field access
fmt.Println(e.Department) // Employee's own field
}
Employee is not a "subclass" of Name — it's a separate
type that contains a Name value. The methods are
promoted, not inherited. e.Greet() is shorthand for
e.Name.Greet(). If the outer type defines a method with the same name,
it shadows the inner type's method — no method is removed, just hidden.
Struct tags
Struct fields can carry metadata in backtick-quoted strings, which libraries like
encoding/json, database/sql, and configuration frameworks
read via reflection. Tags are not part of the Go language — they're a convention
honored by specific libraries:
package main
import (
"encoding/json"
"fmt"
)
type User struct {
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age,omitempty"`
SSN string `json:"-"` // excluded from output
}
func main() {
u := User{Name: "Alice", Email: "a@b.com"}
b, _ := json.Marshal(u)
fmt.Println(string(b))
}
Tags use space-separated key:"value" pairs, all inside backticks.
omitempty omits the field from JSON output when it has its zero value.
json:"-" means "always skip this field." The tag values are parsed by
each library independently — json tags only affect JSON encoding,
db tags only affect database mapping, etc. If you misquote or mistype
the key name, the library silently ignores the tag and uses defaults — a bug
that's very hard to spot.
Slices & maps
A slice is not the array itself — it's a small header holding a pointer to a backing array, a length, and a capacity. Slicing an existing slice doesn't copy any data; the new slice points into the same backing array. That's convenient and fast, but it means two slices can silently affect each other.
package main
import "fmt"
func main() {
s := make([]int, 0, 2)
fmt.Println("len:", len(s), "cap:", cap(s))
s = append(s, 1, 2)
fmt.Println("len:", len(s), "cap:", cap(s))
s = append(s, 3)
fmt.Println("len:", len(s), "cap:", cap(s))
a := []int{1, 2, 3}
b := a[:2]
b[0] = 99
fmt.Println("a:", a, "b:", b)
}
The third append pushes past the capacity of 2, so Go allocates a new,
bigger backing array (capacity jumped to 4 here) and copies the existing elements
over — from that point on, the old and new arrays are independent.
b[0] = 99 changing a[0] too shows the opposite case: since
b := a[:2] never exceeded the original array's capacity, no new array
was allocated, so a and b still share memory.
Doubling from capacity 2 to 4 above is what this Go 1.26 build actually does, and we ran it to confirm it — but the Go language specification doesn't commit to any particular growth algorithm. Treat "capacity grows by some amount when exceeded" as guaranteed; treat the exact factor as an implementation detail that could change between Go versions.
Maps behave more predictably around missing keys, but nil maps have a sharp edge:
package main
import "fmt"
func main() {
m := map[string]int{"a": 1, "b": 2}
v, ok := m["c"]
fmt.Println(v, ok)
m["c"] = 3
delete(m, "a")
fmt.Println(len(m))
var nilMap map[string]int
v2, ok2 := nilMap["x"]
fmt.Println(v2, ok2)
}
One more thing worth knowing before it surprises you: iterating over a map with
for k, v := range m visits entries in an order that is deliberately
unspecified and not guaranteed to stay the same between runs. If
you need a predictable order, collect and sort the keys yourself.
Reading from a nil map (one only declared, never make'd)
is completely safe, as shown above. Writing to one is not:
package main
func main() {
var m map[string]int
m["x"] = 1 // panic: assignment to entry in nil map
}
Initialize with make(map[string]int) (or a map literal) before writing.
copy(): safe slice duplication
When you need an independent copy of a slice's data, use the built-in
copy(). It copies elements from one slice to another, up to the length
of the shorter one — no risk of overshooting:
package main
import "fmt"
func main() {
a := []int{1, 2, 3}
b := make([]int, len(a))
n := copy(b, a) // copies elements from a into b
fmt.Println("copied", n, "elements")
b[0] = 99
fmt.Println("a:", a) // unchanged
fmt.Println("b:", b) // independent copy
}
Unlike b := a[:2] in the shared-backing-array example above,
copy(b, a) creates a new array that b owns exclusively.
Mutating b cannot affect a. This is the intentional,
idiomatic way to get an independent slice.
Common slice operations
Slices are Go's most versatile collection type. A handful of patterns come up in almost every Go codebase:
package main
import "fmt"
func main() {
s := []int{1, 2, 3, 4, 5}
// Filter (keep odd numbers)
odds := s[:0]
for _, v := range s {
if v%2 != 0 {
odds = append(odds, v)
}
}
fmt.Println("filtered (odds):", odds) // reuses s's backing array!
// Reverse in-place
for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
s[i], s[j] = s[j], s[i]
}
fmt.Println("reversed:", s)
// Clear (zero elements, keep capacity)
clear := s[:0]
fmt.Println("cleared len:", len(clear), "cap:", cap(clear))
}
The filter trick odds := s[:0] creates a zero-length slice that still
points into s's backing array. Each append writes into
that existing memory rather than allocating a new one — as long as the
filtered result doesn't exceed s's capacity, it's an in-place filter
with zero extra allocations. This is a common performance pattern.
Interfaces
An interface lists methods; any type that has all of them satisfies it
automatically — there's no implements keyword to
write, and a type can satisfy an interface it was never written with in mind.
Shape just by having the right method — nothing was declared.package main
import "fmt"
type Shape interface {
Area() float64
}
type Circle struct{ Radius float64 }
func (c Circle) Area() float64 { return 3.14159 * c.Radius * c.Radius }
type Square struct{ Side float64 }
func (s Square) Area() float64 { return s.Side * s.Side }
func printArea(s Shape) {
fmt.Printf("%.2f\n", s.Area())
}
func main() {
printArea(Circle{Radius: 2})
printArea(Square{Side: 3})
}
printArea only knows about Shape — it never mentions
Circle or Square by name, so it works with any current or
future type that has an Area() float64 method.
This one surprises almost everyone the first time. An interface value is really
two parts: a concrete type and a value. It's only equal to
nil when both parts are unset. If you return a nil pointer
through an interface-typed return value, the type part gets set (to that pointer
type) even though the value part is nil — so the interface itself is not nil.
package main
import "fmt"
type MyError struct{}
func (e *MyError) Error() string { return "boom" }
func mayFail(bad bool) *MyError {
if bad {
return &MyError{}
}
return nil // a nil *MyError
}
func check(bad bool) error {
var p *MyError = mayFail(bad)
return p // always returns a non-nil error interface!
}
func main() {
err := check(false)
fmt.Println(err == nil)
}
Even though mayFail(false) returns a nil pointer, and nothing ever
failed, err == nil prints false. The fix: return the
untyped literal nil directly from check when there's no
error, instead of a nil-valued variable of a concrete pointer type.
Type assertions: extracting a concrete value
Once you have an interface value, you may need to get the concrete type back out. A type assertion takes an interface value and attempts to extract a specific concrete type from it:
package main
import "fmt"
var x interface{} = 42
func main() {
v, ok := x.(int) // safe assertion: ok = false if wrong type
fmt.Println(v, ok)
w, ok := x.(string) // fails: x holds int, not string
fmt.Println(w, ok)
z := x.(string) // unsafe assertion: panics on mismatch!
_ = z
}
The single-value form x.(Type) panics if the assertion fails. The
two-value form v, ok := x.(Type) returns false for
ok and a zero v — no panic, and you handle the
failure gracefully with an if check. This is the same pattern as the
two-value channel receive: always prefer the ok variant unless you are
absolutely certain about the type.
Type switches: branching on dynamic types
When you need to handle multiple possible concrete types from a single interface
value, use a type switch — a switch that matches
on the type rather than the value:
package main
import (
"fmt"
"strconv"
)
func describe(v interface{}) {
switch x := v.(type) {
case int:
fmt.Println("int:", x)
case string:
fmt.Println("string (len", len(x), "):", x)
case bool:
fmt.Println("bool:", x)
case nil:
fmt.Println("nil!")
default:
fmt.Println("unknown type")
}
}
func main() {
describe(42)
describe("hello")
describe(true)
describe(nil)
describe(complex(1, 2)) // falls to default
}
Inside each case block, the variable x has the concrete
type of that branch — not interface{}. So in
case int:, x is usable as an int with no
extra assertion needed. This is handled by the compiler, not by runtime magic.
Error handling
Go treats errors as ordinary values, not exceptions. A function that can fail
returns an extra error result, and the caller checks it — the
ubiquitous if err != nil. Since Go 1.13, errors can also be
wrapped, preserving the original error while adding context.
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func find(id int) error {
if id != 1 {
return fmt.Errorf("find id %d: %w", id, ErrNotFound)
}
return nil
}
func main() {
err := find(2)
fmt.Println(err)
fmt.Println(errors.Is(err, ErrNotFound))
}
%w in fmt.Errorf wraps ErrNotFound inside a
new, more specific error — the printed message includes both. Wrapping doesn't
just concatenate strings, though: errors.Is(err, ErrNotFound) can still
see through the wrapping and correctly reports true, because it walks
the chain of wrapped errors looking for a match rather than comparing the top-level
error directly.
Custom error types: carrying structured data
The sentinel pattern (errors.New) works when an error needs nothing more
than a message. But often an error should carry context — a status code, a
field name, a timestamp. Any struct with an Error() string method
satisfies the error interface:
package main
import (
"errors"
"fmt"
)
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: field %q — %s", e.Field, e.Message)
}
func validate(name string) error {
if name == "" {
return &ValidationError{Field: "name", Message: "required"}
}
return nil
}
func main() {
err := validate("")
fmt.Println(err)
// Extract structured data from the error
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("bad field:", ve.Field)
fmt.Println("reason:", ve.Message)
}
}
Use a pointer receiver (*ValidationError) on the
Error() method. If you use a value receiver, the type doesn't
implement error when stored as a pointer — which is how most
errors flow through Go code. This is the same value-vs-pointer distinction from
the structs section above, and it trips people up constantly.
errors.As: extracting typed errors from a chain
errors.Is checks whether an error is a specific value.
errors.As does something different: it walks the wrap chain and
extracts the first error that matches a given type, giving you access to its
fields. This is how you recover the structured data a custom error carries:
package main
import (
"errors"
"fmt"
"io"
)
type HTTPError struct {
StatusCode int
Err error
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("http %d: %s", e.StatusCode, e.Err)
}
func (e *HTTPError) Unwrap() error { return e.Err }
func fetch(url string) error {
return &HTTPError{
StatusCode: 404,
Err: io.EOF,
}
}
func main() {
err := fetch("/missing")
fmt.Println(err)
// Check for the sentinel deep in the chain
fmt.Println("is EOF?", errors.Is(err, io.EOF))
// Extract the typed error to access its fields
var he *HTTPError
if errors.As(err, &he) {
fmt.Println("status:", he.StatusCode)
}
}
errors.Is vs errors.As
errors.Is(err, target) checks equality — it
walks the wrap chain looking for an error equal to target. Use it for
sentinel values like io.EOF or sql.ErrNoRows.
errors.As(err, &target) checks type — it
walks the chain looking for an error assignable to *target, then copies
it. Use it when you need to recover fields from a custom error type. The target
must be a pointer to a value of the error type you're looking for.
Unwrap() requirement
Both errors.Is and errors.As only walk chains connected
by Unwrap(). The standard fmt.Errorf("%w", err) calls
create an Unwrap() automatically. But if you build your own error type
that wraps another error, you must implement Unwrap() error yourself
— otherwise the chain is invisible to Is/As. If
your custom error wraps multiple errors (e.g., a multi-error), implement
Unwrap() []error instead (Go 1.20+).
panic and recover: Go's exception-like mechanism
Go has a built-in panic function that stops normal execution and
unwinds the stack, running any deferred functions along the way. If a
deferred function calls recover(), it captures the panic value and
normal execution resumes. But this is not Go's error-handling
mechanism — it's reserved for truly unrecoverable situations.
package main
import "fmt"
func safeDiv(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
return a / b, nil // panics when b == 0
}
func main() {
r, err := safeDiv(10, 0)
fmt.Println(r, err)
}
panic is for situations where a program cannot possibly continue:
corrupted invariants, programmer errors (index out of range with impossible
bounds), or initialization failures. It is not for "this API call
failed" or "this user input was invalid" — those are normal errors returned
via the error interface.
The recover pattern above exists mainly at the boundaries of
goroutines (a panic that escapes a goroutine crashes the whole program) and
in HTTP servers (where each request runs in its own goroutine and a panic in one
request must not kill the server). Outside those contexts, prefer returning errors
explicitly.
Interview Questions
Common interview questions about structs, slices, maps, interfaces, and error handling in Go — organized by experience level. Click a tab to filter.
Q1. What is a struct in Go, and how do you define one?
A struct is a composite type that groups named fields together, each with its own type. You declare one with the type keyword, e.g. type Point struct { X, Y int }, and create values with a struct literal like Point{X: 1, Y: 2}. Structs are the primary way Go models records without classes.
Q2. What's the difference between a value receiver and a pointer receiver on a method?
A value receiver, func (p Point) Foo(), operates on a copy of the struct, so changes inside the method don't affect the original. A pointer receiver, func (p *Point) Foo(), operates on the original struct through its address, so mutations persist. Pointer receivers also avoid copying large structs on every call.
Q3. How do you create a slice, and how is it different from an array?
An array has a fixed size baked into its type, e.g. [3]int, while a slice, e.g. []int, is a resizable view over an underlying array. You create slices with a literal ([]int{1, 2, 3}), make([]int, len, cap), or by slicing an existing array or slice. Slices carry a pointer, length, and capacity rather than storing the data inline.
Q4. What is the zero value of a map, and what happens if you read from it before initializing?
The zero value of a map is nil. Reading from a nil map is safe and returns the zero value for the value type, as if the key were absent, but writing to a nil map with m[key] = value panics at runtime. Maps must be initialized with make(map[K]V) or a map literal before you can write to them.
Q5. What is an interface in Go, and how does a type satisfy one?
An interface defines a set of method signatures without any implementation. A concrete type satisfies an interface automatically, with no implements keyword, simply by defining methods with matching names, parameters, and return types. This is called implicit (structural) satisfaction.
Q6. What is the empty interface any used for?
any (an alias for interface{} since Go 1.18) has zero methods, so every type satisfies it. It's used when a function or container needs to accept a value of any type, such as fmt.Println(args ...any), at the cost of losing compile-time type information until a type assertion or switch is used.
Q7. What is the error interface in Go?
error is a built-in interface with a single method, Error() string. Any type that implements that method can be used as an error value, and Go's convention is to return errors as the last value from a function rather than using exceptions.
Q8. What's the difference between errors.New and fmt.Errorf?
errors.New("message") creates a simple error with a static message. fmt.Errorf("...: %v", err) lets you build a formatted error message that can include other values, and with the %w verb it also wraps an existing error so the original can later be recovered with errors.Unwrap, errors.Is, or errors.As.
Q9. What is struct embedding?
Embedding places one type inside another without giving it a field name, e.g. type Manager struct { Employee }. The outer type automatically gets promoted access to the embedded type's fields and methods, giving a form of composition that Go uses instead of classical inheritance.
Q1. Why can appending to a slice sometimes silently share the backing array with the original slice, and sometimes not?
append only allocates a new backing array when the slice's length would exceed its capacity. If there's spare capacity, the append writes into the existing array in place, so the original slice (and any other slice sharing that array) can see the mutated elements. Once capacity is exceeded, Go allocates a fresh, larger array and copies the data, silently severing the connection to the original.
Q2. Why is map iteration order randomized in Go?
The Go runtime deliberately randomizes the starting point (and internal bucket ordering) of a range over a map so that programs cannot accidentally come to depend on a particular iteration order. This was a language design choice made early on specifically to prevent code from becoming implicitly reliant on an implementation detail that could change between runs or versions.
Q3. What are the rules that determine whether a value or pointer receiver method can be called on a given value?
A value of type T can call methods with either a T or *T receiver as long as the value is addressable, because Go automatically takes its address for you. A value that is not addressable (like a map element or a literal) can only call value-receiver methods. A *T can always call both value and pointer receiver methods.
Q4. Explain the "nil interface with non-nil concrete type" gotcha.
An interface value is only nil when both its underlying type and its underlying value are nil. If you assign a nil pointer of a concrete type, e.g. var p *MyErr = nil, to an interface variable, the interface itself is non-nil because it carries type information even though the value is nil. So a function returning that interface will make err != nil true even though the pointer is nil.
Q5. Why does comparing errors with == break when using wrapped errors, and what should you use instead?
== only checks if two error values are identical, but a wrapped error created via fmt.Errorf("...: %w", sentinel) is a different value than sentinel itself, so == returns false even though the original error is logically present. errors.Is walks the chain of wrapped errors (via Unwrap) to check whether any of them match the target, which is the correct way to test for a specific sentinel error.
Q6. How does %w in fmt.Errorf differ from %v or %s when formatting an error?
%v and %s just format the error's message as text into the new error string, with no relationship preserved between the two errors. %w does the same formatting but also records the wrapped error so it implements Unwrap() error, letting errors.Is and errors.As traverse back to it later.
Q7. Why can two slices that look identical (same length and values) have very different capacities and appending behavior?
A slice's capacity depends on how it was created — a full slice expression, a subslice of a larger array, or a result of previous appends can all leave different amounts of spare room in the backing array even if the visible elements match. Because append's decision to reallocate depends on capacity, not length, two "equal-looking" slices can behave very differently: one may mutate a shared array in place while the other allocates a new one.
Q8. Why doesn't a map guarantee insertion order, and how do you get sorted output?
Go's map is a hash table, and its internal bucket layout has no relationship to the order keys were inserted, plus the runtime deliberately randomizes traversal on top of that. To produce deterministic, sorted output, collect the keys into a slice, sort the slice (e.g. with sort.Strings or slices.Sort), and then iterate the keys in that order to look up values.
Q9. When should you use panic/recover versus returning an error?
Errors are the idiomatic way to signal expected, recoverable failure conditions, like a missing file or invalid input, and should be returned as normal values. panic is reserved for programmer errors or truly unrecoverable situations (like an out-of-bounds index or nil dereference), and recover is typically only used at a boundary, such as an HTTP middleware, to prevent one failing request from crashing the whole process.
Q1. When does embedding cause method-set ambiguity, and how does Go resolve it?
If a struct embeds two types that both define a method with the same name at the same depth, calling that method on the outer struct is ambiguous and the compiler rejects it — you must qualify it explicitly, e.g. x.TypeA.Foo(). If the same method name exists at different embedding depths, the shallowest one wins automatically with no ambiguity error, which can silently shadow a deeper method rather than combine with it.
Q2. How do errors.Is and errors.As differ from each other and from ==?
== compares two error values directly and fails across wrapping. errors.Is(err, target) walks the Unwrap chain looking for a value equal to target (or satisfying an Is(error) bool method), used to test for a specific sentinel error. errors.As(err, &target) instead walks the chain looking for an error whose concrete type matches target's type, assigning it in so you can access that type's fields or methods.
Q3. Explain how an error wrapping chain works with multiple %w verbs.
Since Go 1.20, fmt.Errorf can include more than one %w verb in a single format string, producing an error whose Unwrap() []error method returns all of the wrapped errors instead of just one. errors.Is and errors.As both understand this multi-error form and will search every branch of the tree, and errors.Join provides a similar way to combine several errors into one without a format string.
Q4. How does interface satisfaction differ for a type's pointer receiver method set versus its value receiver method set, and what bugs can this cause?
The method set of T includes only methods declared with a value receiver, while the method set of *T includes both value and pointer receiver methods. This means a value of type T (not *T) does not satisfy an interface if any required method has a pointer receiver, which surfaces as a confusing compile error when someone tries to pass a plain struct value where a pointer was needed to implement the interface.
Q5. How does append's growth strategy interact with aliasing bugs when a function receives a slice and appends to it?
append grows capacity by roughly doubling for small slices and by a smaller multiplier for larger ones, but whether a given append reuses the existing array or allocates a new one depends entirely on whatever spare capacity happens to exist at that call site. This makes it unsafe to assume a function's append either does or doesn't mutate the caller's backing array; the three-index slice expression s[:len:cap] is the standard way to cap a slice's capacity and force any further append to allocate, protecting the caller's data.
Q6. What's the exact behavior of recover() when it isn't called correctly?
recover only stops a panic when it is called directly inside a deferred function running as part of that panic's unwind; calling it directly in normal code, or indirectly through another function called by the deferred function, has no effect. In any of those cases, and whenever there is no panic in progress at all, recover() simply returns nil and the panic (if any) continues to propagate.
Q7. When is a struct type comparable with ==, and when does that comparison happen at compile time versus runtime?
A struct is comparable with == only if every one of its fields is itself comparable; fields of slice, map, or function type make the whole struct non-comparable. If the struct's type is statically known at compile time, the compiler rejects an invalid == outright; but if the comparison happens through two interface values whose static type is any, the non-comparability is only discovered at runtime and triggers a panic like "comparing uncomparable type".
Q8. Why can comparing two interface values with == panic at runtime even though the code compiles fine?
Comparing two any-typed values compiles because any is always comparable at the type-check level, but the actual comparison at runtime first checks that the two dynamic types match and then compares the dynamic values. If the dynamic type held by the interface is itself non-comparable, such as a slice or a struct containing one, the runtime comparison panics instead of returning a boolean.
Q9. What's the tradeoff between sentinel errors, custom error types, and wrapping when designing an error-handling strategy?
Sentinel errors (package-level var ErrNotFound = errors.New(...)) are simple and let callers check identity with errors.Is, but they carry no extra data and create coupling to that exact variable. Custom error types (a struct implementing Error() string) let callers extract structured fields via errors.As, at the cost of more boilerplate. Wrapping with %w lets a function add context at each layer while preserving the ability for any caller, at any depth, to still test against the original sentinel or type.