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.
- what an interface and the empty interface
anyare: 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.
reflect.Type,reflect.Value, Kind and why it is not the same as the type;CanAddr,CanSet,Elemand the panic texts;- struct tags,
reflect.DeepEqual,Value.IsNiland the cost of reflection in nanoseconds.
What is really being asked
The ladder is almost always this:
- "What is reflection and where is it needed?" — the warm-up:
encoding/json,fmt, ORMs, validators. - "How does
reflect.Typediffer fromreflect.Kind?" — checks whether the candidate sees the difference between a type and its structure. - "Why does
SetIntpanic?" — the settability rule, the main question of the topic. - "Can you change a private field through
reflect?" — a refinement of the same rule. - "How does
encoding/jsonlearn a field's name in JSON?" — tags. - "How does
reflect.DeepEqualdiffer from==?" — and where it answers something other than expected. - "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 entry into reflection is two functions, and both take an any.
Signatures from go doc reflect, fragment:
func TypeOf(i any) Type
func ValueOf(i any) ValueSo 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 .:
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 obtained | CanAddr | CanSet | write |
|---|---|---|---|
reflect.ValueOf(x) | false | false | SetInt — panic |
reflect.ValueOf(&x) | false | false | SetInt — panic |
reflect.ValueOf(&x).Elem() | true | true | SetInt changes x |
an unexported field via Elem() | true | false | SetInt — panic |
reflect.ValueOf(s), s a slice | false | false | Set — panic |
reflect.ValueOf(s).Index(0) | true | true | SetInt changes s[0] |
reflect.ValueOf(m), m a map | false | false | SetMapIndex 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.
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:
type Celsius intOutput 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):
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.
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.
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.
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:
return v.flag&(flagAddr|flagRO) == flagAddrflagAddr 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:
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, SetXxxThe 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:
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.
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.
Libraries give a tag its meaning, and they read it through reflect.StructTag.
Fragment of bench/goreflect/internals.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.
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.
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
"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.
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 do | what you pay for |
|---|---|
FieldByName on every call | walking the fields and building a StructField |
Field(i) by a remembered index | ValueOf, Elem, the flag check |
a remembered field Value + SetInt | only the flag and Kind check |
| an ordinary assignment | one 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
How do you check through reflect that an interface holds nil?
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:
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.
How do you walk all fields of a struct and read their tags?
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:
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).
When is reflection justified and when is it not?
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
reflect.ValueOf(x) gives access to the variable x
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.
To change a value, it is enough to pass a pointer to ValueOf
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().
Without a pointer nothing can be changed through reflect
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.
Type and Kind are the same thing
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.
An unexported field is invisible to reflect
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.
Get and Lookup on a tag return the same thing
Not for an empty key. For json:"" and for a field without a tag Get("json") gives "", while Lookup gives "", true versus "", false.
reflect.DeepEqual considers an empty slice equal to nil
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.
Reflection is slow because it allocates all the time
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
| Version | Change | What it means for code |
|---|---|---|
| Go 1.20 | reflect.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.22 | The 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.23 | Value.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.24 | encoding/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.25 | The 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.27 | encoding/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
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
Knowledge check
x := 3; v := reflect.ValueOf(x); v.SetInt(42). What happens?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- Reflection is working with a value whose type is known only at run time.
reflect.TypeOfandreflect.ValueOftake ananyand return a description of what sits inside the interface:reflect.Typeis the type itself,reflect.Valueis the value together with the rights to it. This is howencoding/json,fmtand 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 ofx— in the run, afterx = 7.1it still shows3.4— andSetInton it panics:reflect: reflect.Value.SetInt using unaddressable value. What changesxisreflect.ValueOf(&x).Elem()— the dereferenced pointer, that is, the variable itself. What is reachable through a value is another matter: a slice element changes throughreflect.ValueOf(s).Index(0)without&s, and a map entry throughSetMapIndex, even thoughCanSetof the map'sValueisfalse. What decides isCanSetof the specificValue, and other methods have conditions of their own. - Next comes what separates understanding from recitation. For the named type
Celsiusoverintthe Type ismain.Celsius, while the Kind isint, and code that needs "all integers" switches on Kind. An unexported field of a struct reached through a pointer is addressable and readable, but itsCanSetisfalse, andInterface()panics.encoding/jsonlearns names from tags throughStructTag.Get, andGetdoes not tell an empty key from a missing one —Lookupdoes.reflect.DeepEqualconsiders 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 coresFieldByNamecost 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.
In fact
- It gets a copy —
ValueOftakes anany. In the run, afterx = 7.1the valuev.Float()stayed3.4, andSetInton such a value panics:using unaddressable value. reflect.ValueOf(&x)describes the pointer itself (Kindptr), and it is not addressable either:CanSetisfalse, the same panic. What changesxisreflect.ValueOf(&x).Elem().- The variable itself cannot be replaced. But
reflect.ValueOf(s).Index(0).SetInt(7)changes the caller's slice element — in the runs = [7 2 3]— andSetMapIndexwrites into a map even thoughCanSetof itsValueisfalse. For an array, by contrast,ValueOf(a).Index(0)is an element of a copy, and itsCanSetisfalse. - For
Celsius(21)the Type ismain.Celsius, the Kind isint, andTypeOf(Celsius(21)) == TypeOf(21)givesfalse. A type switch withcase intdoes not recognize it, a switch on Kind does. - It is visible and readable:
age.Int()returned30,fmtprints it. It cannot be changed (CanSetisfalse) and cannot be obtained throughInterface()— both calls panic. - Not for an empty key. For
json:""and for a field without a tagGet("json")gives"", whileLookupgives"", trueversus"", false. - It does not:
[]int(nil)and[]int{}givefalse, and this is written in the documentation. A function, meanwhile, is equal only tonil— the same non-nil function compared with itself givesfalse. - 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
FieldByNamecost 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.
By version
- Go 1.20
reflect.ValuegainedSetZero,ComparableandEqual(the fileapi/go1.20.txt).<- Go 1.22
- The function
reflect.TypeFor[T]()(api/go1.22.txt): a type from a type parameter, without a value.< - Go 1.23
Value.SeqandValue.Seq2(api/go1.23.txt): walking a value with arangeover a function.<- Go 1.24
encoding/jsongained theomitzerotag option. Release notes: "When marshaling, a struct field with the newomitzerooption in the struct field tag will be omitted if its value is zero". The lesson did not measure it.<- Go 1.25
- The 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 andfalserather than panicking; on a field obtained through an unexported name it panics, just likeInterface(). Checked with a run on go1.25.1 — below.< - Go 1.27
encoding/jsonis now backed by the v2 implementation. Release notes: "Marshaling and unmarshaling behavior is preserved, but the exact text of error messages may differ". Theencode.goline numbers in the lesson are from go1.24.7; the lesson was not run on 1.27.<
What is covered
- What is really being asked
- Base: a value goes into an interface and comes back
- Mechanism 1: Type is the type, Kind is its structure
- Mechanism 2: the settability rule — `CanSet`
- Mechanism 3: an unexported field — readable, but not changeable
- Mechanism 4: tags — where `encoding/json` gets names from
- Mechanism 5: `DeepEqual` — "equal" without the `==` operator
- Deeper: what reflection costs and where the cost comes from
- How to answer in an interview
- Next they ask
- Common misconceptions
- Version history
- Practice
- Knowledge check
Sources & further reading
6 SOURCES
- 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
- 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
- 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
- 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
- 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
- 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