Reflection on plain values shows what kind of thing you're holding, but most real reflection work happens against structs: pulling field names for a SQL row mapper, dumping a config for a debug endpoint, or writing a generic JSON-like serializer. This chapter walks through the API the reflect package exposes for that: how to iterate fields, how to read each field's metadata, how to read and write field values, and how Go's export rules constrain what you can do with unexported fields.
reflect.TypeOf and reflect.ValueOf return wrappers that describe any Go value. When the value is a struct (or a pointer to a struct), those wrappers expose two more methods that let you iterate its fields: Type.NumField() returns how many fields the struct has, and Type.Field(i) returns a StructField describing the i-th one.
NumField panics if the underlying Type isn't a struct, so the first thing real code does before iterating is check t.Kind() == reflect.Struct. The order of fields is the source order from the struct declaration, not alphabetical or hashed. That stability matters: a serializer that writes fields in declaration order produces deterministic output, which is important for diffs and golden tests.
Type.Field(i) doesn't return the field's value. It returns a StructField, which is pure metadata: the field's name, declared type, struct tag, and a handful of flags. To get the actual value at runtime, you go through reflect.Value instead, which we'll cover after looking at what StructField carries.
Cost: Each Type.Field(i) call is cheap, but it does allocate a fresh StructField value with copies of the name and tag strings. For a struct used in a hot path, cache the slice of fields once at startup rather than iterating on every call.
A StructField is a struct itself, defined in the reflect package. The fields you'll actually use are:
| Field | Type | What it carries |
|---|---|---|
Name | string | The field's identifier as written in the source |
Type | reflect.Type | The field's declared type, ready for further reflection |
Tag | reflect.StructTag | The raw backtick tag string, with helpers to parse it |
Index | []int | Path of indices used to reach the field, important for embedded structs |
Anonymous | bool | True when the field was declared without a name (embedded) |
PkgPath | string | Empty for exported fields, non-empty for unexported ones |
Here's all of those in one place against a struct that exercises each one.
The Tag field stores the literal text between the backticks; parsing it into key:"value" pairs is what StructTag.Get and StructTag.Lookup do. The Index slice has length 1 for top-level fields. The Audit field is marked Anonymous: true because it has no field name in the source, only a type. And PkgPath is empty for every exported field. The instant a field is unexported, PkgPath becomes the importing package's path, and that field becomes off-limits to most reflection operations.
The relationship between Type, NumField, Field(i), and StructField is easier to keep straight in one picture.
Type gives you the shape of the struct: how many fields, what each one looks like. StructField is the per-field record, with everything reflection knows about that field at compile time. The actual runtime value lives in a parallel reflect.Value, which we'll wire up next.
Type.Field(i) describes the field; Value.Field(i) returns its value. The two are paired by index, so iterating with the same i against the same struct gives matching metadata and runtime data.
rv.Field(i) returns a reflect.Value holding that field's data. Calling .Interface() on it produces an interface{} (or any) containing the runtime value, which is what fmt knows how to format. There's also .Int(), .String(), .Float(), and so on for direct access without going through an interface, but those panic if the field's kind doesn't match: calling .Int() on a float64 field crashes the program. .Interface() is the safer default for generic printers and the one most code reaches for.
This is the core of every "dump any struct" helper in the standard library and beyond: fmt.Printf("%+v", x) walks fields exactly like this, and so does encoding/json when it doesn't have a cached encoder for the type.
When you already know the field name you want, iterating is overkill. Value.FieldByName(name) and Type.FieldByName(name) jump straight to it.
FieldByName returns the zero reflect.Value (whose IsValid() reports false) when no field with that name exists. There's no error, no panic, just a sentinel. Code that does lookups against arbitrary names always has to check IsValid() before using the result, otherwise the next operation (.String(), .Int(), anything) is the one that panics.
The matching is exact and case-sensitive: FieldByName("name") won't find a field declared as Name. The encoding/json package uses its own matching logic that's case-insensitive as a fallback, but plain reflection doesn't.
Cost: FieldByName does a linear scan over the struct's fields on every call. For one-shot lookups that's fine. For a tight loop, look the field's index up once and reuse it, or build a map[string]int at startup keyed by name.
Reflection respects Go's visibility rules. An unexported field (one whose name starts with a lowercase letter) can be read by Type.Field and observed in metadata, but the resulting reflect.Value won't let you call Interface() on it. The package this matters to is the reflect package itself, which uses PkgPath to detect unexported fields and refuses to hand the value out.
PkgPath is the canonical check: an empty PkgPath means the field is exported. Value.CanInterface() returns false for the same fields, and trying to call .Interface() anyway triggers a panic with the message reflect.Value.Interface: cannot return value obtained from unexported field or method. This is the same kind of guardrail that keeps encoding/json from accidentally serializing a password field that someone forgot to capitalize.
Reading the unexported field's metadata (its name, type, tag) is still fine. What's blocked is reading or writing the actual value through reflection, because that would let any package bypass the export rules. There's an escape hatch using unsafe.Pointer plus reflect.NewAt that gets around it, but that's specifically for cases like memory-mapped IO and ORMs that have explicit reason to violate visibility. Use it only when you have a clear reason.
So you can detect the field, see its kind, see its name, even check whether CanInterface() would let you read it. The actual data is just behind a one-way door. For a code generator or a debug printer, the practical pattern is "skip the field if CanInterface() is false", which mirrors what encoding/json does.
Reading is one half. Writing through reflection is harder because of two rules layered on top of the visibility check:
reflect.Value you write through must be addressable, which means it has a stable memory address you can point at.A reflect.Value made from a plain value isn't addressable. reflect.ValueOf(p) takes a copy, and there's no way to write back into the original. To get an addressable value, you take the address yourself first: reflect.ValueOf(&p).Elem(). The Elem() call dereferences the pointer and gives you a settable view of the underlying struct.
Three new methods showed up: .SetString, .SetFloat, and .SetInt. Each one panics if the kind doesn't match (you can't call .SetString on an int field) and panics if the value isn't settable. Notice the SetInt call has to cast through int64, because all integer-setting goes through that width regardless of the field's actual type. The reverse path, .Int() on a smaller integer, returns int64 too.
Forgetting the &p is the most common mistake. Try it without and reflection gives you a clear message:
The fix is reflect.ValueOf(&p).Elem(). That's the canonical incantation: pass a pointer in, call Elem to step inside it, and now every field of p is settable through the returned value.
Even with addressability, unexported fields stay locked. Value.CanSet() reports the combined state: true only when the value is addressable AND the field is exported.
Even though the struct is addressable through the pointer, password still can't be written via reflection because it's unexported. The same defense that blocks Interface() on unexported fields blocks Set and the typed setters too. A generic setter (a JSON decoder, an ORM mapper, a config loader) should skip fields where CanSet() is false rather than trying and recovering from the panic.
The full set of rules is worth summarizing in one place.
| Method | Requires | What happens otherwise |
|---|---|---|
Value.Field(i) | Underlying type is a struct | Panics on non-struct |
Value.FieldByName(s) | Underlying type is a struct | Returns zero Value if not found |
Value.Interface() | Field is exported | Panics: cannot return unexported value |
Value.Set(x) / Value.SetString(s) etc | Field is exported AND addressable | Panics: not settable |
Value.CanInterface() | Always safe | Returns false when blocked |
Value.CanSet() | Always safe | Returns false when blocked |
The two Can... methods are how robust code stays clear of panics. Reflection has plenty of sharp edges, and the standard library's own decoders (the JSON encoder, the XML decoder, the text/template engine) all check these before doing anything.
Real structs nest. A Customer has an Address field that's its own struct. An Order embeds Audit for CreatedBy and CreatedAt. A walker that flattens these into a single field list, or that prints the whole tree, needs to recurse.
Two cases matter:
StructField.Anonymous is true. The Index slice gives the path to reach them.Here's a printer that handles both.
Address shows up indented because the walker recursed. CreatedBy shows up at the top level without being wrapped in an Audit block, because by default Go's compiler promotes embedded fields, and Value.Field(i) for an embedded struct still returns the embedded struct value. The recursion handles it the same way as a named field; the only difference visible to the walker is f.Anonymous, which the walker could check if it wanted to print the embedding hierarchy explicitly.
What about reading the embedded fields without recursing? Type.FieldByName("CreatedBy") finds them directly thanks to the Index path, even though they live one level down in the actual layout.
Index is [1, 0], which means "field 1 of the outer struct, then field 0 of that struct". Value.FieldByIndex(field.Index) walks the path and returns the leaf value, which is the underlying string. For non-embedded fields the path is a single integer like [0]. For deeply embedded fields it can be longer. Most code never needs to look at Index directly because FieldByName does the path walking for you, but a struct walker that has to remember "I already saw this field" needs the index to deduplicate correctly.
Cost: Recursion is fine for the structs you write by hand, but a generic walker over user-controlled input must guard against cycles. A struct with a pointer to itself (a linked list node, for example) walked naively will loop forever. Track visited pointers in a map[uintptr]bool keyed by Value.Pointer(), and bail when you see one twice.
Putting the pieces together, here's a small helper that updates a struct's fields from a map[string]any. It's the shape of a dozen real-world tools: a config loader, a request-body decoder, an HTML form binder.
The function leans on every rule we've covered. It rejects non-pointer or nil-pointer targets up front, because without addressability nothing else works. It uses FieldByName with an IsValid check to detect typos. It uses CanSet to refuse unexported fields cleanly, with an error rather than a panic. And it does a type check with AssignableTo before calling Set, because Set itself panics on type mismatch. Production-grade reflection code looks a lot like this: a checklist of pre-conditions before every operation.
Notice that cost does have a matching field in the struct, but the function still rejects it because CanSet returns false. That's the same wall that encoding/json runs into and skips without error. The choice to error here, rather than skip silently, is a design call: a config loader probably wants the error, a forgiving JSON decoder might want to skip.
Every example above happily reads StructField.Tag and shows the raw backtick text. Turning a tag string like ` json:"price" db:"price_cents" into the values price and price_cents is what StructTag.Get and StructTag.Lookup do. For now, the only thing to remember is that Tag is just a string sitting on StructField. Reflection itself doesn't interpret it: any parsing convention (the json: prefix, the omitempty option, the db:` mapping) is a contract between whoever writes the tag and whoever reads it.
10 quizzes