Core

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 Slices & maps Interfaces Error handling Interview Questions

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.

original
Counter{count: 0}
what IncByPointer() mutates
copy (inside IncByValue)
Counter{count: 0}
discarded the moment the method returns
Try both buttons and watch which box actually changes.
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)
}
after value receiver: 0 after pointer receiver: 1

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.

Go writes the & for you, usually

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
}
Hello, Alice! Alice Sales
Embedding is not inheritance

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))
}
{"name":"Alice","email":"a@b.com","age":0}
Tag syntax is easy to misread

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.

1
2
3
a[0]
a[1]
a[2]
b[0]
b[1]
a := []int{1, 2, 3}, then b := a[:2]. Both point into the same backing array.
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)
}
len: 0 cap: 2 len: 2 cap: 2 len: 3 cap: 4 a: [99 2 3] b: [99 2]

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.

The exact growth factor isn't a language guarantee

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)
}
0 false 2 0 false

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.

Common mistake: writing to a nil map

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
}
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
}
copied 3 elements a: [1 2 3] b: [99 2 3]

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))
}
filtered (odds): [1 3 5] reversed: [5 4 3 2 1] cleared len: 0 cap: 5
Filtering with s[:0] reuses memory

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.

Circle has Area() float64
Square has Area() float64
Both satisfy 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})
}
12.57 9.00

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.

The classic gotcha: a non-nil interface holding a nil value

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.

type
nil
value
nil
err == nil: (not run yet)
An interface is nil only when both its type and value are unset.
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)
}
false

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
}
42 true 0 false panic: interface conversion: interface {} is int, not string
Always use the two-value form

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
}
int: 42 string (len 5): hello bool: true nil! unknown type
The variable in a type switch changes type per case

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))
}
find id 2: not found true

%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)
	}
}
validation: field "name" — required bad field: name reason: required
Pointer receiver on Error()

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)
	}
}
http 404: EOF is EOF? true status: 404
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.

The 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)
}
0 recovered: runtime error: integer divide by zero
Don't use panic for normal error handling

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.

Continue to Advanced →