Deep Engineering
Intermediate·Published·50 MIN

Reflection in Go: reflect.Type, reflect.Value and the settability rule

Interview questions on reflection climb a ladder: what reflect.Type and reflect.Value are — how Type differs from Kind — why SetInt panics — whether a private field can be changed — how encoding/json learns names from tags — how DeepEqual differs from == — what all of it costs. A run answers each step: ValueOf sees a copy, and that copy cannot be changed; for Celsius the Type is main.Celsius while the Kind is int; an unexported field can be read but not changed; a nil slice is not equal to an empty one even for DeepEqual; and in a measurement on go1.24.7 looking a field up by name cost more than ten times as much as writing through a ready index.

Full technical treatment

TL;DR

Reflection is working with a value whose type is known only at run time. reflect.TypeOf and reflect.ValueOf take an any and return a description of what sits inside the interface: reflect.Type is the type itself, reflect.Value is the value together with the rights to it. This is how encoding/json, fmt and every library that walks the fields of someone else's structs are built.

The main consequence: the variable itself can be changed only if you reached it through a pointer. reflect.ValueOf(x) holds a copy of x — in the run, after x = 7.1 it still shows 3.4 — and SetInt on it panics: reflect: reflect.Value.SetInt using unaddressable value. What changes x is reflect.ValueOf(&x).Elem() — the dereferenced pointer, that is, the variable itself. What is reachable through a value is another matter: a slice element changes through reflect.ValueOf(s).Index(0) without &s, and a map entry through SetMapIndex, even though CanSet of the map's Value is false. What decides is CanSet of the specific Value, and other methods have conditions of their own.

Next comes what separates understanding from recitation. For the named type Celsius over int the Type is main.Celsius, while the Kind is int, and code that needs "all integers" switches on Kind. An unexported field of a struct reached through a pointer is addressable and readable, but its CanSet is false, and Interface() panics. encoding/json learns names from tags through StructTag.Get, and Get does not tell an empty key from a missing one — Lookup does. reflect.DeepEqual considers a nil slice unequal to an empty one, and a non-nil function unequal even to itself. And the cost is above all the lookup by name: in a measurement on go1.24.7, linux/amd64, 2 cores FieldByName cost 13.1 times as much as writing through an index found in advance, with no allocations. The mechanism explains where the difference comes from; its size on your machine has to be measured.

Where to start
Before this lesson it is enough to understand
  • what an interface and the empty interface any are: a value together with its dynamic type — this is covered in detail in the lesson on interfaces, which comes later in the track; this sentence is enough here;
  • that a slice is a header with a pointer to a shared array, and a map is a reference to a table: the lessons on slices and maps;
  • how to declare a struct and how an exported field (capitalized) differs from an unexported one — this is the lesson on structs;
  • what a pointer and dereferencing are — the lesson on pointers.
You do not need to know in advance
  • reflect.Type, reflect.Value, Kind and why it is not the same as the type;
  • CanAddr, CanSet, Elem and the panic texts;
  • struct tags, reflect.DeepEqual, Value.IsNil and the cost of reflection in nanoseconds.

What is really being asked

The ladder is almost always this:

  1. "What is reflection and where is it needed?" — the warm-up: encoding/json, fmt, ORMs, validators.
  2. "How does reflect.Type differ from reflect.Kind?" — checks whether the candidate sees the difference between a type and its structure.
  3. "Why does SetInt panic?" — the settability rule, the main question of the topic.
  4. "Can you change a private field through reflect?" — a refinement of the same rule.
  5. "How does encoding/json learn a field's name in JSON?" — tags.
  6. "How does reflect.DeepEqual differ from ==?" — and where it answers something other than expected.
  7. "What does reflection cost?" — and what libraries do about it.

The ladder has one spine: a reflect.Value describes a specific value that came through an interface, and what you can do with it is decided by how you got it. Got a copy — you can read. Reached it through a pointer — you can change. Went through an unexported name — read-only again. And for a slice or a map the contents are reachable even without a pointer — a copy of the header leads to the same data.

Base: a value goes into an interface and comes back

The package describes itself in one sentence:

Package reflect implements run-time reflection, allowing a program to manipulate objects with arbitrary types.

— The reflect package — overview
language contractThe rules of settability, addressability and access to unexported fields are written in the reflect package documentation; the run of bench/goreflect/internals.go illustrates them rather than establishes them.

The entry into reflection is two functions, and both take an any.

Signatures from go doc reflect, fragment:

GO
func TypeOf(i any) Type
func ValueOf(i any) Value

So the path is always the same: the value is first put into an interface, and the package gets the type and data out of the interface. The way back is the Value.Interface() method, which returns an any again. For an introduction the package documentation points to the article "The Laws of Reflection"; here the same rules are worked through with a run. The way there and back is in the first section.

Output of bench/goreflect/internals.go, section 1, without explanatory lines; labels translated:

1) THERE AND BACK: INTERFACE -> REFLECT.VALUE -> INTERFACE
──────────────────────────────────────────────────────────
  reflect.TypeOf(x)        -> float64
  v.Kind()                 -> float64
  v.Float()                -> 3.4
  v.Interface().(float64)  -> 3.4
  x = 7.1; v.Float()       -> 3.4

The last line is the key to the whole lesson. After x = 7.1 the value v is still 3.4: a copy of x went into ValueOf, as a copy goes with any argument passed in Go. v describes that copy, not the variable.

That is enough for a Junior-level answer: reflection is a way to learn at run time the type of a value sitting in an interface, read it and, if the rules allow, change it. reflect.TypeOf returns the type, reflect.ValueOf — the value; Interface() returns it back into an any. Where it is needed: wherever code works with types it does not know at compile time — serialization, fmt, ORMs, tag-based validators.

All of this fits into a short program. The full file bench/goreflect/cases/example/main.go can be copied into an empty directory next to a go.mod (module example, go 1.24) and run with go run .:

GO
package main
 
import (
	"fmt"
	"reflect"
)
 
func main() {
	x := 3
	v := reflect.ValueOf(x) // a copy of x
	fmt.Println(v.CanSet())
 
	e := reflect.ValueOf(&x).Elem() // the variable x itself
	e.SetInt(42)
	fmt.Println(e.CanSet(), x)
 
	s := []int{1, 2, 3}
	reflect.ValueOf(s).Index(0).SetInt(7) // an element of the shared array
	fmt.Println(s)
}

Output of bench/goreflect/example.sh; the header label translated:

go1.24.7 linux/amd64 | full example: go run .

  false
  true 42
  [7 2 3]

The first two lines are the rule for the variable itself, the third is the refinement for containers; both are covered in Mechanism 2.

One table is worth memorizing right away — it answers the third question of the ladder. An addressable value is one that has an address in memory, like a variable under &x; the exact rule is in Mechanism 2, along with why the table looks like this:

how the Value was obtainedCanAddrCanSetwrite
reflect.ValueOf(x)falsefalseSetInt — panic
reflect.ValueOf(&x)falsefalseSetInt — panic
reflect.ValueOf(&x).Elem()truetrueSetInt changes x
an unexported field via Elem()truefalseSetInt — panic
reflect.ValueOf(s), s a slicefalsefalseSet — panic
reflect.ValueOf(s).Index(0)truetrueSetInt changes s[0]
reflect.ValueOf(m), m a mapfalsefalseSetMapIndex changes m

Mechanism 1: Type is the type, Kind is its structure

The first fork where people get confused. The documentation defines Kind like this:

A Kind represents the specific kind of type that a Type represents.

— The reflect package — Kind

In practice this means: Type is the whole type, with its name and package; Kind is one of a fixed set of kinds (int, struct, ptr, slice, map…). A check on a named type over int.

Fragment of bench/goreflect/internals.go:

GO
type Celsius int

Output of bench/goreflect/internals.go, section 2, without explanatory lines; labels translated:

2) TYPE VERSUS KIND
───────────────────
  value              Type             Kind
  Celsius(21)        main.Celsius     int
  21                 int              int
  User{}             main.User        struct
  &User{}            *main.User       ptr
  []Celsius{}        []main.Celsius   slice
  map[string]int{}   map[string]int   map
  TypeOf(Celsius(21)) == TypeOf(21)  -> false

Celsius and int are different types, and their reflect.Type values are not equal. But their structure is the same — Kind int. Hence the practical rule: code that must work with any type over int switches on Kind, not on the type.

Fragment of bench/goreflect/internals.go, shortened (... marks omitted branches; strings translated):

GO
func byKind(i any) string {
    v := reflect.ValueOf(i)
    switch v.Kind() {
    case reflect.Int, reflect.Int64:
        return fmt.Sprintf("integer: %d", v.Int())
    ...
}
 
func byType(i any) string {
    switch x := i.(type) {
    case int:
        return fmt.Sprintf("integer: %d", x)
    ...
}

Output of the same section 2, continued; labels translated:

  byKind(Celsius(21))      -> "integer: 21"
  byType(Celsius(21))      -> "unknown: main.Celsius"

The type switch sees main.Celsius and does not land in case int. encoding/json is built the same way: it picks the encoder for a type with a switch t.Kind() (encoding/json/encode.go:408 in go1.24.7), so Celsius is an ordinary integer to it.

One more observation from the same table: the Kind of &User{} is ptr, of []Celsius{} — slice. Kind tells you what to do next: for a pointer call Elem(), for a struct walk the fields, for a slice — the elements. That is how walking an arbitrary value is built — a switch on Kind sits both in encoding/json and in fmt (fmt/print.go:777).

Mechanism 2: the settability rule — CanSet

The central question of the topic. The rule is written in the documentation as one sentence:

A Value can be changed only if it is addressable and was not obtained by the use of unexported struct fields.

— The reflect package — Value.CanSet

And what "addressable" means:

A value is addressable if it is an element of a slice, an element of an addressable array, a field of an addressable struct, or the result of dereferencing a pointer.

— The reflect package — Value.CanAddr

Checking three ways to get a Value for x := 3.

Output of bench/goreflect/internals.go, section 3, without explanatory lines; labels translated:

3) THE SETTABILITY RULE: CanSet
───────────────────────────────
  ValueOf(x)          Kind=int  CanAddr=false CanSet=false
  ValueOf(&x)         Kind=ptr  CanAddr=false CanSet=false
  ValueOf(&x).Elem()  Kind=int  CanAddr=true  CanSet=true
  ValueOf(x)          .SetInt(42) -> panic: reflect: reflect.Value.SetInt using unaddressable value
  ValueOf(&x)         .SetInt(42) -> panic: reflect: reflect.Value.SetInt using unaddressable value
  ValueOf(&x).Elem()  .SetInt(42) -> x = 42

The explanation follows from the Base. ValueOf(x) holds a copy of x; if SetInt changed it, x would stay the same, and the mistake would pass silently. The package makes it loud: the copy has no address, CanSet is false, SetInt panics.

ValueOf(&x) is a frequent second attempt. A pointer went into the interface — again as a copy: the Value describes the pointer itself (Kind ptr), and a pointer sitting in an interface is not addressable. The same panic. Only Elem() dereferences the pointer — and by the rule above the result of dereferencing is addressable. That is the variable x, and SetInt(42) writes straight into it.

implementation detail · Go 1.24The structure of reflect.Value and the line numbers are from reflect/value.go in go1.24.7; this is an implementation detail, not part of the documented contract.

Inside, it is three words and bit flags. reflect.Value is a struct of a pointer to the type, a pointer to the data and a flag field (reflect/value.go:39–63). CanSet is a single flag check (value.go:354).

Fragment of reflect/value.go:354, go1.24.7:

GO
return v.flag&(flagAddr|flagRO) == flagAddr

flagAddr is set by Elem() on a pointer (value.go:1261), and setters check the same flags before writing and panic with one of two texts (value.go:251–261): about an unaddressable value or about an unexported field. The second text is the next mechanism.

Hence a working template for code that changes values through reflect.

A function fragment — a template for your own code, not part of the run:

GO
v := reflect.ValueOf(ptr)
if v.Kind() != reflect.Pointer || v.IsNil() {
    return errors.New("a non-nil pointer is required")
}
v = v.Elem() // from here on v is addressable: fields, elements, SetXxx

The same check sits in json.Unmarshal (encoding/json/decode.go:170 in go1.24.7) — which is why you pass it &dst, not dst.

Slices and maps: the contents can be changed without &

The "only through a pointer" rule is about the variable itself — replacing its value. What is reachable through a value follows its own conditions, and for containers they differ. A check on a slice, an array and a map.

Output of bench/goreflect/internals.go, section 8, without explanatory lines; labels translated:

8) CONTAINERS: SLICE, ARRAY AND MAP WITHOUT A POINTER
─────────────────────────────────────────────────────
  ValueOf(s)               CanAddr=false CanSet=false
  ValueOf(s).Index(0)      CanAddr=true  CanSet=true
  ValueOf(s).Index(0).SetInt(7)  -> s = [7 2 3]
  ValueOf(s).Set(ValueOf([]int{9}))  -> panic: reflect: reflect.Value.Set using unaddressable value
  ValueOf(a).Index(0)      CanAddr=false CanSet=false   (a is an array [3]int)
  ValueOf(m)               CanAddr=false CanSet=false
  ValueOf(m).SetMapIndex("b", 2)  -> m = map[a:1 b:2]
  ValueOf(m).MapIndex("a") CanAddr=false CanSet=false
  ValueOf(m).MapIndex("a").SetInt(5) -> panic: reflect: reflect.Value.SetInt using unaddressable value

A slice. ValueOf(s) is a copy of the slice header, and the slice itself cannot be replaced (Set) without &s — the same panic as for a number. But the header points to a shared array, so Index(0) of a slice is always addressable: Value.Index sets flagAddr on it unconditionally (reflect/value.go:1423 in go1.24.7). SetInt(7) changes the caller's s[0] — in the run s = [7 2 3].

An array is the opposite. ValueOf(a) copies it whole, and an element of the copy inherits its non-addressability (value.go:1410): CanSet is false.

A map. The map's Value has CanSet set to false, but writing into a map is done not by Set but by SetMapIndex, and it needs no address — only that the map was not obtained through an unexported field (reflect/map_swiss.go:407–410 in go1.24.7). In the run m = map[a:1 b:2]. A value read through MapIndex, though, is a copy: SetInt on it panics.

All three cases repeat the addressability rule of the language itself. The specification (section Address operators) lists what is addressable: "either a variable, pointer indirection, or slice indexing operation; or a field selector of an addressable struct operand; or an array indexing operation of an addressable array". Slice indexing is on the list unconditionally, array indexing only for an addressable array, and a map element is not on the list at all.

Hence the precise wording: Set and SetInt work when CanSet of the specific Value is true; SetMapIndex and other methods have their own conditions, written in their documentation. Having an address does not by itself mean SetInt applies: by Kind it does not fit a pointer or a map.

Mechanism 3: an unexported field — readable, but not changeable

The second half of the rule. A struct with an unexported field.

Fragment of bench/goreflect/internals.go:

GO
type User struct {
    Name string
    age  int
}
 
u := User{Name: "Ann", age: 30}
v := reflect.ValueOf(&u).Elem()
name, age := v.FieldByName("Name"), v.FieldByName("age")

Output of bench/goreflect/internals.go, section 4, without explanatory lines; labels translated:

4) AN UNEXPORTED FIELD: READABLE, BUT NOT CHANGEABLE
────────────────────────────────────────────────────
  field Name: CanAddr=true CanSet=true
  field age:  CanAddr=true CanSet=false
  age.Kind()               -> int
  age.Int()                -> 30
  age.Interface()          -> panic: reflect.Value.Interface: cannot return value obtained from unexported field or method
  age.SetInt(31)           -> panic: reflect: reflect.Value.SetInt using value obtained using unexported field
  name.SetString("Bob")    -> u = {Name:Bob age:30}
  json.Marshal(u)          -> {"Name":"Bob"}

The field age is addressable — it was reached through a pointer, and by the addressability rule a field of an addressable struct is addressable. But CanSet is false: the second half of the rule forbids changing what was obtained through an unexported name. Reading is allowed — age.Int() returned 30.

Interface() panics too, and that is no accident. The source (reflect/value.go:1492–1494) explains why: "Do not allow access to unexported values via Interface, because they might be pointers that should not be writable or methods or function that should not be callable". If Interface() handed out an unexported pointer, one could write through it what SetInt forbids.

implementation detail · Go 1.24Line numbers are from reflect/value.go and fmt/print.go in go1.24.7.

The read-only mark is set at the moment the field is obtained: Value.Field adds the flagStickyRO flag if the field is unexported and not embedded (value.go:1284–1289; an embedded one gets flagEmbedRO), and from there the flag is inherited by everything obtained from that field.

Two libraries in the run behave differently, and both follow this rule. fmt prints the field age ({Name:Bob age:30}): it reads it with methods like Int(), and calls Interface() — and with it the value's String and Error methods — only when CanInterface() holds (fmt/print.go:768). encoding/json leaves it out of the output altogether: it skips unexported non-embedded fields (encoding/json/encode.go:1130–1132).

So the answer to "can you change a private field through reflect" is: no — its CanSet is false, and the setter panics. Reading is allowed.

Mechanism 4: tags — where encoding/json gets names from

To the language itself a field tag means almost nothing:

The tags are made visible through a reflection interface and take part in type identity for structs but are otherwise ignored.

— The Go Specification — Struct types

Libraries give a tag its meaning, and they read it through reflect.StructTag.

Fragment of bench/goreflect/internals.go:

GO
type Account struct {
    ID     int    `json:"id"`
    Email  string `json:"email,omitempty" db:"email_addr"`
    Note   string `json:""`
    Secret string `json:"-"`
    Plain  string
}

Output of bench/goreflect/internals.go, section 5, without explanatory lines; labels translated:

5) STRUCT TAGS: Get AND Lookup
──────────────────────────────
  field   Get("json")          Lookup("json")
  ID      "id"                 "id", true
  Email   "email,omitempty"    "email,omitempty", true
  Note    ""                   "", true
  Secret  "-"                  "-", true
  Plain   ""                   "", false
  Email.Tag.Get("db")      -> "email_addr"
  json.Marshal             -> {"id":1,"Note":"n","Plain":"p"}

The Note and Plain rows are what gets asked about. Get returned "" for both: Plain has no json key, while Note has one, but it is empty. Only Lookup tells them apart, and the documentation says so directly:

To determine whether a tag is explicitly set to the empty string, use StructTag.Lookup.

— The reflect package — StructTag.Get

encoding/json does not even need to tell them apart: it reads the tag through Get("json") (encoding/json/encode.go:1134 in go1.24.7), and an empty name means "take the field's name" to it — in the output Note and Plain came out under their own names. And "-" is a special case from the package documentation: 'As a special case, if the field tag is "-", the field is always omitted'. There is no Secret in the JSON.

Two practical conclusions. A tag is just a string in the type, and its meaning is set by a library, not by the language: per the specification, apart from reflection and type identity, tags are ignored, so what omitempty means is decided by encoding/json. And a tag can hold several keys — db:"email_addr" on the same field is read by its own library with its own Get("db"), without getting in the way of encoding/json.

Mechanism 5: DeepEqual — "equal" without the == operator

For slices, maps and functions == is allowed only against nil, and for pointers it compares addresses. reflect.DeepEqual compares contents — but by its own rules, and three of them go against intuition.

Output of bench/goreflect/internals.go, section 6, without explanatory lines; labels translated:

6) DEEPEQUAL: CONTENTS, NOT THE == OPERATOR
───────────────────────────────────────────
  []int(nil) and []int{}          -> false
  []int{1,2} and []int{1,2}       -> true
  maps {a:1,b:2} and {b:2,a:1}    -> true
  &User{Ann,30} and &User{Ann,30} -> true
  User{Ann,30} and User{Ann,31}   -> false
  Celsius(1) and 1                -> false
  f and f (the same function)     -> false
  func()(nil) and func()(nil)     -> true
  NaN and NaN                     -> false
  the same pointers via ==        -> false

A nil slice is not equal to an empty one. For len they are the same, for range too, yet DeepEqual tells them apart. This is written in the documentation:

Note that a non-nil empty slice and a nil slice (for example, []byte{} and []byte(nil)) are not deeply equal.

— The reflect package — DeepEqual

Tests fail on this: a function returned []int{}, the expectation says nil (or the other way round) — and a comparison through DeepEqual gives false, even though "both are empty".

A function is not equal even to itself. The rule in the documentation is short: "Func values are deeply equal if both are nil; otherwise they are not deeply equal". The same non-nil f gave false in the run. So a struct with a filled-in function field is not equal to its copy through DeepEqual either.

Different types are never equal — Celsius(1) and 1 gave false, although their Kind is the same. This is the same Mechanism 1: DeepEqual compares the types first.

The rest is as expected. Maps are compared by pairs, insertion order does not matter; pointers to equal structs are equal, although == on the same pointers gives false; unexported fields are compared too — age 30 and 31 gave false. And NaN is not equal to itself, as in an ordinary comparison of numbers.

Deeper: what reflection costs and where the cost comes from

measured observationbench/goreflect/cost.go, go1.24.7 linux/amd64, 2 cores, GOMAXPROCS=2; the Port field is the seventh of eight; the result is written to a global variable; best of 15 rounds of 300 ms (a minimum, not a typical time), the rounds of the methods interleaved. The machine is shared: nanoseconds wander from run to run. This is an observation of one configuration; on another machine, version or struct the ratio has to be measured again.

"Reflection is slow" is an answer without a number. The measurement breaks down what the cost is made of: the same field Port (the seventh of eight) is written in four ways.

Output of bench/goreflect/cost.go, section 1, without explanatory lines; labels translated:

1) WRITING THE PORT FIELD FOUR WAYS
───────────────────────────────────
  field Port — seventh of eight, index 6

  method                                 ns/op  allocs bytes
  cfg.Port = n                            0.33      0     0
  ValueOf(&cfg).Elem().FieldByName       76.21      0     0
  ValueOf(&cfg).Elem().Field(idx)         5.82      0     0
  field.SetInt (field Value kept)         2.02      0     0

  FieldByName costs more than Field(idx)        13.1 times
  Field(idx) costs more than a kept Value        2.9 times

Three conclusions, in decreasing order of importance.

The lookup by name is the most expensive part. Value.FieldByName asks the type whether it has a field with that name (reflect/value.go:1348–1354). The type walks its fields and compares the names (reflect/type.go:1284–1287). Once it finds the field, it builds the full StructField description — type, name, tag, offset (reflect/type.go:1101–1126), and only then does the Value move to the field by index. In the run that is 76.21 ns against 5.82 for a write through a ready index.

The name comparisons are not the main part of that cost. The same script separately measures the lookup alone, without a write — of the first field and of the seventh.

Output of bench/goreflect/cost.go, section 2, without explanatory lines; labels translated:

2) LOOKUP BY NAME: THE FIRST FIELD VERSUS THE SEVENTH
─────────────────────────────────────────────────────
  lookup only, no write                  ns/op  allocs bytes
  FieldByName("Host") — 1st field        53.97      0     0
  FieldByName("Port") — 7th field        76.18      0     0

  lookup of 1st field / of 7th field     0.71

The first field takes one comparison, the seventh takes seven, yet the cost differs far less than sevenfold: the first field costs 0.71 of the seventh. Most of the cost is constant — building the StructField and moving to the field once it has been found. The six extra comparisons add the rest. In the neighbouring run of practice.go a write through FieldByName took 78.77 ns against 7.10. In both runs on this machine the ratio is above ten; the mechanism explains where it comes from, and its size has to be measured on your own machine.

None of the ways allocates — an observation of the go1.24.7 implementation, not a guarantee of the package. The pointer &cfg goes into the interface without copying the struct, reflect.Value is a three-word value, and the field index (StructField.Index, a slice) is taken from a static table for the first 256 fields rather than allocated (reflect/type.go:1120–1126).

A direct assignment is a fraction of a nanosecond, one memory write. The script deliberately does not print a ratio to it: the divisor is under a nanosecond, and the ratio would be noise.

implementation detail · Go 1.24The encoding/json field cache and line numbers are from encoding/json/encode.go in go1.24.7; this is how the package is built, not its documented contract.

Hence what libraries do: they look fields up by name once per type and remember the result. encoding/json keeps a fieldCache — a sync.Map from reflect.Type to the field description — and parses a struct's tags only on the first encounter with its type (encoding/json/encode.go:1325–1332). Your own code that walks fields in a hot loop should do the same: a reflect.Type can be a map key, and the path to a field is kept as the []int from StructField.Index and passed to Value.FieldByIndex. For a top-level field that slice has one element — then Field(i) is enough; for a field of an embedded struct it has more (see "Next they ask").

A summary table that is handy to keep in mind:

what you dowhat you pay for
FieldByName on every callwalking the fields and building a StructField
Field(i) by a remembered indexValueOf, Elem, the flag check
a remembered field Value + SetIntonly the flag and Kind check
an ordinary assignmentone memory write

How to answer in an interview

Short answer: reflection in Go is the reflect package, which, given a value in an interface, yields its type (reflect.Type) and the value itself (reflect.Value). Reading is always allowed — an unexported field through Int(), String() and the like, but not through Interface(). Set and SetInt work only if CanSet of the specific Value is true: the value is addressable and was not obtained through an unexported field; that is why, to replace the variable itself, you pass a pointer and call Elem(). A slice element and a map entry are reachable even without a pointer — Index and SetMapIndex have their own conditions. encoding/json, fmt and everything that works with types unknown at compile time use it; the cost is above all the lookup by name, and libraries cache it per type.

That is enough to answer correctly. Next comes what people add if the interviewer keeps digging.

If the interviewer digs deeper

On "why does SetInt panic" — the copy and the address. "ValueOf(x) got a copy of x; changing it would change nothing, so CanSet is false, and the panic is using unaddressable value. ValueOf(&x) describes the pointer itself — also a copy. What changes x is ValueOf(&x).Elem(): a dereferenced pointer is addressable. For a slice, though, ValueOf(s).Index(0).SetInt(7) changes s[0] even without &s — a slice element is always addressable; a map is written with SetMapIndex, which needs no CanSet."

On "how does Type differ from Kind" — a named type. "For type Celsius int the Type is main.Celsius, the Kind is int. A type switch with case int will not catch it, a switch on Kind will; that is why walking values in libraries is a switch v.Kind()."

On "can you change a private field" — the read-only flag. "The field is addressable if you came through a pointer, but it was obtained through an unexported name: CanSet is false, SetInt panics, so does Interface(). Reading is allowed — Int(), String(); that is how fmt prints private fields."

On "how does json learn names" — tags. "A tag is a string in the type; json reads it through StructTag.Get("json"). "-" means skip, an empty name means take the field's name. Get does not tell an empty key from a missing one, Lookup does."

On "DeepEqual versus ==" — three exceptions. "It compares contents, but a nil slice is not equal to an empty one, a function is equal only to nil — not even to itself, and values of different types are never equal."

On "what does it cost" — the mechanism and a measurement with its conditions. "You pay for the lookup by name: on every call FieldByName walks the fields and builds a StructField. That is why encoding/json parses a type once and keeps a cache. In my measurement on go1.24.7, linux/amd64 a write through FieldByName cost 13.1 times as much as a write through a ready index, with no allocations; on another machine the number will differ — it gets measured."

Next they ask

Next they ask

How do you check through reflect that an interface holds nil?

Short answer

This is the "nil that is not nil" question — it is covered in detail in the lesson on interfaces, Mechanism 3. In short: an interface with a nil pointer inside is not equal to nil, because its first word — the type — is filled in. reflect lets you look inside.

Output of bench/goreflect/internals.go, section 7, without explanatory lines; labels translated:

7) nil INSIDE AN INTERFACE: IsNil AND THE ZERO VALUE
────────────────────────────────────────────────────
  i := any((*User)(nil)); i == nil     -> false
  reflect.ValueOf(i).Kind()            -> ptr
  reflect.ValueOf(i).IsNil()           -> true
  var e any; e == nil                  -> true
  reflect.ValueOf(e).IsValid()         -> false
  reflect.ValueOf(e).IsNil()           -> panic: reflect: call of reflect.Value.IsNil on zero Value
  reflect.ValueOf(42).IsNil()          -> panic: reflect: call of reflect.Value.IsNil on int Value

Two traps. IsNil is defined only for a pointer, map, slice, channel, function and interface — on an int it panics. And for an empty interface (var e any) ValueOf returns the zero Value, and IsNil on it panics too. The documentation warns: "IsNil is not always equivalent to a regular comparison with nil in Go".

So the check is written in two steps — first == nil, then Kind.

Fragment of bench/goreflect/internals.go:

GO
func isNil(i any) bool {
    if i == nil {
        return true
    }
    v := reflect.ValueOf(i)
    switch v.Kind() {
    case reflect.Pointer, reflect.Map, reflect.Slice, reflect.Chan, reflect.Func, reflect.Interface:
        return v.IsNil()
    }
    return false
}

Output of the same section 7, the next line; labels translated:

  isNil(i), isNil(e), isNil(42)        -> true, true, false

But the right answer in an interview does not start with it: such a function usually treats the symptom. If a function is declared to return error, it should return a literal nil, not a typed nil pointer — then the check err == nil works without reflection.

Next they ask

How do you walk all fields of a struct and read their tags?

Short answer

Through the type: NumField gives the number of fields, Field(i) — the field description reflect.StructField with the name, type and tag. Section 5 of the run is built exactly like this.

Fragment of bench/goreflect/internals.go, section 5:

GO
t := reflect.TypeOf(Account{})
for i := 0; i < t.NumField(); i++ {
    f := t.Field(i)
    val, ok := f.Tag.Lookup("json")
    fmt.Printf("  %-7s %-20q %q, %v\n", f.Name, f.Tag.Get("json"), val, ok)
}

Field values are taken from a reflect.Value by the same index: v.Field(i). If a field may turn out to be unexported, check CanInterface() or CanSet() before Interface() or a write — otherwise you get the panic from Mechanism 3. In embedded structs fields are nested, and the full path to them is a slice of indexes (StructField.Index, Value.FieldByIndex).

Next they ask

When is reflection justified and when is it not?

Short answer

It is justified when the types are unknown at compile time: serialization to JSON and other formats, fmt, ORMs, tag-based validation, dependency injection. This is library code, and there the cost is paid once per type — by caching the parsing, as encoding/json does.

It is not justified when there are few types and they are known: then switch x := i.(type) is shorter, checked by the compiler and does not panic on a typo. And when you need one algorithm for different types — generics: the compiler catches mistakes, not a panic at run time.

The sign of overuse is visible in the code: reflect in business logic rather than in the layer that serves arbitrary types.

Common misconceptions

Claim

reflect.ValueOf(x) gives access to the variable x

Actually

It gets a copy — ValueOf takes an any. In the run, after x = 7.1 the value v.Float() stayed 3.4, and SetInt on such a value panics: using unaddressable value.

Claim

To change a value, it is enough to pass a pointer to ValueOf

Actually

reflect.ValueOf(&x) describes the pointer itself (Kind ptr), and it is not addressable either: CanSet is false, the same panic. What changes x is reflect.ValueOf(&x).Elem().

Claim

Without a pointer nothing can be changed through reflect

Actually

The variable itself cannot be replaced. But reflect.ValueOf(s).Index(0).SetInt(7) changes the caller's slice element — in the run s = [7 2 3] — and SetMapIndex writes into a map even though CanSet of its Value is false. For an array, by contrast, ValueOf(a).Index(0) is an element of a copy, and its CanSet is false.

Claim

Type and Kind are the same thing

Actually

For Celsius(21) the Type is main.Celsius, the Kind is int, and TypeOf(Celsius(21)) == TypeOf(21) gives false. A type switch with case int does not recognize it, a switch on Kind does.

Claim

An unexported field is invisible to reflect

Actually

It is visible and readable: age.Int() returned 30, fmt prints it. It cannot be changed (CanSet is false) and cannot be obtained through Interface() — both calls panic.

Claim

Get and Lookup on a tag return the same thing

Actually

Not for an empty key. For json:"" and for a field without a tag Get("json") gives "", while Lookup gives "", true versus "", false.

Claim

reflect.DeepEqual considers an empty slice equal to nil

Actually

It does not: []int(nil) and []int{} give false, and this is written in the documentation. A function, meanwhile, is equal only to nil — the same non-nil function compared with itself gives false.

Claim

Reflection is slow because it allocates all the time

Actually

In the field-write measurement none of the ways allocates. The cost here is the lookup by name: in a measurement on go1.24.7, linux/amd64 FieldByName cost 13.1 times as much as a write through a ready index. The absence of allocations is an observation of that version, not a guarantee.

Version history

VersionChangeWhat it means for code
Go 1.20reflect.Value gained SetZero, Comparable and Equal (the file api/go1.20.txt).Zero a value in one call, compare two Values without Interface()
Go 1.22The function reflect.TypeFor[T]() (api/go1.22.txt): a type from a type parameter, without a value.A Type is obtained by the type's name, without a value
Go 1.23Value.Seq and Value.Seq2 (api/go1.23.txt): walking a value with a range over a function.Slices, arrays, maps, channels and strings are walked with one range
Go 1.24encoding/json gained the omitzero tag option. Release notes: "When marshaling, a struct field with the new omitzero option in the struct field tag will be omitted if its value is zero". The lesson did not measure it.One more key json reads from the tag
Go 1.25The function func TypeAssert[T any](v Value) (T, bool) (api/go1.25.txt). Per the documentation it is equivalent to the two-result form: v2, ok := v.Interface().(T). On a type mismatch it returns the zero value and false rather than panicking; on a field obtained through an unexported name it panics, just like Interface(). Checked with a run on go1.25.1 — below.A type assertion with ok straight from a Value
Go 1.27encoding/json is now backed by the v2 implementation. Release notes: "Marshaling and unmarshaling behavior is preserved, but the exact text of error messages may differ". The encode.go line numbers in the lesson are from go1.24.7; the lesson was not run on 1.27.Links into the encoding/json source may not match

TypeAssert in a run on go1.25.1: the right type gives the value and true, the wrong one gives the zero value and false, like a type assertion with ok; without ok an ordinary assertion panics. The panic on an unexported field is a different one and does not depend on the type. The full module is bench/goreflect/cases/typeassert (go 1.25 in go.mod).

Output of bench/goreflect/typeassert.sh on go1.25.1; labels translated:

go1.25.1 linux/amd64 | reflect.TypeAssert — two results, like comma-ok

  TypeAssert[int](ValueOf(1))           -> 1, true
  TypeAssert[string](ValueOf(1))        -> "", false
  v.Interface().(string) with ok        -> "", false
  v.Interface().(string) without ok     -> panic: interface conversion: interface {} is int, not string
  TypeAssert[int](field age)            -> panic: reflect.TypeAssert: cannot return value obtained from unexported field or method

What it was checked on. All of the lesson's runs are go1.24.7 and go1.25.1, linux/amd64; reconciled on 14.10.2026. The settability rule, the panic texts and the behaviour of DeepEqual did not change between these versions: internals.go on go1.25.1 prints the same thing apart from the version line (runs/internals-go1.25.txt). Go 1.26 and Go 1.27 have been released since; the lesson was not run on them, and the table rows about them come from the release notes.

Practice

Two tasks. Answer first, then check against the real output: in both, the correct answer is taken from the script's run, not assigned.

Practice · predict the output

A fragment of bench/goreflect/practice.go: a type declaration and part of main. The type Celsius is declared over int. The variable x equals 3; v was obtained from x, p — through a pointer to x and Elem(). Through p, 10 was written. What does this code print?
type Celsius int

x := 3
v := reflect.ValueOf(x)
fmt.Println(v.CanSet())
p := reflect.ValueOf(&x).Elem()
p.SetInt(10)
fmt.Println(x, v.Int())
fmt.Println(reflect.TypeOf(Celsius(21)).Kind())
fmt.Println(reflect.DeepEqual([]int(nil), []int{}))

Practice · estimate

A struct of eight fields; the seventh, Port, is written. How many times more does a write through reflection with a lookup by name, FieldByName("Port").SetInt, cost than a write through Field(idx) with an index found in advance?
times

Knowledge check

Question 1 of 5

x := 3; v := reflect.ValueOf(x); v.SetInt(42). What happens?

Sources & further reading

6 SOURCES

  1. The reflect package — overviewOfficial documentation. What the package does: «Package reflect implements run-time reflection, allowing a program to manipulate objects with arbitrary types». What a Value is: «A call to ValueOf returns a Value representing the run-time data». And where the package sends you for an introduction: «See "The Laws of Reflection" for an introduction to reflection in Go».https://pkg.go.dev/reflect
  2. The reflect package — Value.CanSet and Value.CanAddrOfficial documentation. The settability rule: «A Value can be changed only if it is addressable and was not obtained by the use of unexported struct fields». What is addressable: «A value is addressable if it is an element of a slice, an element of an addressable array, a field of an addressable struct, or the result of dereferencing a pointer». And about Interface: «It panics if the Value was obtained by accessing unexported struct fields».https://pkg.go.dev/reflect#Value.CanSet
  3. The Go Specification — Struct types (tags)Official documentation. What a tag is to the language: «The tags are made visible through a reflection interface and take part in type identity for structs but are otherwise ignored».https://go.dev/ref/spec#Struct_types
  4. The reflect package — StructTag.Get and Lookup; the encoding/json package — MarshalOfficial documentation. The difference between Get and Lookup: «To determine whether a tag is explicitly set to the empty string, use StructTag.Lookup». How encoding/json reads a tag: «The name may be empty in order to specify options without overriding the default field name» and «As a special case, if the field tag is "-", the field is always omitted».https://pkg.go.dev/reflect#StructTag.Get
  5. The reflect package — DeepEqual and Value.IsNilOfficial documentation. Slices: «Note that a non-nil empty slice and a nil slice (for example, []byte{} and []byte(nil)) are not deeply equal». Functions: «Func values are deeply equal if both are nil; otherwise they are not deeply equal». And IsNil: «Note that IsNil is not always equivalent to a regular comparison with nil in Go».https://pkg.go.dev/reflect#DeepEqual
  6. The reflect package — Value.SetMapIndex and TypeAssert (go1.25)Official documentation. Writing into a map is a separate method with its own conditions: «SetMapIndex sets the element associated with key in the map v to elem»; it does not require CanSet of the map's Value itself. TypeAssert, added in Go 1.25, returns two results: «TypeAssert is semantically equivalent to: v2, ok := v.Interface().(T)».https://pkg.go.dev/reflect#Value.SetMapIndex