# Struct Tags and reflect

> reflect reads Go struct tags at runtime. Lookup tells an empty tag from a missing one; reflect is slow and panics, so keep it at startup, off the hot path.

Source: https://sheng.page/en/posts/go-struct-tags-and-reflection/ · Published: 2025-03-23 · Updated: 2026-11-02 · Author: Sheng

Struct tags are Go's back door for tooling. Gortex's declarative routing reads them with `reflect` to turn a struct into a whole routing table. This post lays the groundwork first.

Correction (2 November 2026, 18:45 Taipei): `Elem()` is now described as accepting a pointer or an interface; unexported fields now panic only when `Interface()` is called; gortex's route registration is now described as matching methods by name without checking signatures first; the claim that a production build swaps in codegen is gone, replaced by the fact that each request still calls the handler through reflect. Suggested by Codex (gpt-6-astra), checked by Claude.

## Reading a struct tag

A struct tag is the raw string after a field, conventionally space-separated `key:"value"` pairs:

```go
type User struct {
    Name string `json:"name" validate:"required"`
}
```

The compiler doesn't interpret it; you read it at runtime with reflect:

```go
f, _ := reflect.TypeOf(User{}).FieldByName("Name")
f.Tag.Get("json")            // "name"
v, ok := f.Tag.Lookup("xml") // "", false
```

The `Get` vs `Lookup` distinction matters: `Get` returns `""` for both "key absent" and "key present but empty", so it can't tell them apart; `Lookup` adds a bool. Gortex detects `inject:""` (an empty-value tag) with `Lookup`, or it would mistake "deliberately empty" for "no tag at all".

## reflect's trio: Type / Value / Kind

- `reflect.Type`: the static structure; field count, names, tags.
- `reflect.Value`: the runtime value, readable and writable.
- `Kind`: the underlying category (`Ptr`, `Struct`, `String`…), distinct from the named type. `type UserID int` has Kind `Int`.

```go
v := reflect.ValueOf(&User{})
v.Kind()         // reflect.Ptr
v.Elem().Kind()  // reflect.Struct; Elem() dereferences one pointer
t := v.Elem().Type()
for i := 0; i < t.NumField(); i++ {
    field := t.Field(i)
    _ = field.Tag.Get("json")
}
```

## Walking safely, checking signatures

reflect doesn't return an error on misuse — it panics. Check before you reach:

- Verify `Kind` first: don't call `NumField()` on a non-struct; `Value.Elem()` accepts only a pointer or an interface and panics on any other Kind.
- Skip unexported fields with `field.CanInterface()` (or `StructField.IsExported()`), or calling `Interface()` on the value panics.
- Before calling a method, get it via `MethodByName`, inspect `Method.Type` (`NumIn` / `NumOut`) to confirm the signature, then `Call`.

This is what route registration in [Struct-Tag Routing: reflect in Practice](/en/posts/gortex-struct-tag-routing/) does: scan fields for `GET` / `POST` methods, then register them. Gortex currently finds handlers by method name alone and registers whatever it finds, without validating parameter or return types, so a wrong signature only fails when a request arrives and `Call` runs. The signature check above is a recommendation that gortex does not yet implement.

## The cost of reflection

reflect is slow (it bypasses static dispatch), sidesteps compile-time type checking, and panics at runtime. The rule is one line: **use it at startup / config time, keep it out of the hot path**. Gortex uses reflect to scan routes at startup for the dev experience, but each request still calls the handler through reflect's `Call`, so that part remains on the request path (see the struct-tag routing post, and [The Zero-Allocation Secret: Context Pool and smartParams](/en/posts/gortex-zero-allocation-context/) for other request-path work).

## From Effective Go: blank identifier, Data

The blank identifier `_` gets a whole section in Effective Go ([The blank identifier](https://go.dev/doc/effective_go#blank)): discarding unwanted return values, `for range` when you only want the index, and the interface check —

```go
var _ io.Writer = (*MyType)(nil) // compile-time proof that *MyType implements io.Writer
```

[Data](https://go.dev/doc/effective_go#data) covers allocation: `new(T)` returns a `*T` to zeroed memory, `make` is reserved for slices / maps / channels. `reflect.New` is the `new` equivalent, and gortex's struct-tag routing uses it to auto-initialise nil handler fields.


## Notes

- A struct tag is the raw string after a field; reflect reads it with `Get` / `Lookup`, and `Lookup` distinguishes "absent" from "present but empty".
- The trio is Type / Value / Kind; Kind is the underlying category, independent of the named type.
- reflect panics on misuse: check Kind first, skip unexported fields, verify a method signature before calling.
- reflect is slow and unsafe: use it at startup, never on the request path.
- Struct tags + reflect are the groundwork for the struct-tag routing post.

Further reading: the series goes on to dissect the gortex framework built on these ideas — source at [yshengliao/gortex](https://github.com/yshengliao/gortex).

---

> Ideas and technical judgement by Sheng; drafted with Claude · Go 1.24 (latest release at the time) · compiled retroactively · part of the 2026-06-13 blog renovation, paint still drying.