How to Read Struct Tags with Reflection in Go

Read Go struct tags at runtime using reflect.TypeOf to get the struct type and Field.Tag.Get to extract specific tag values.

Reading struct tags with reflection

You're building a library that maps structs to database columns. You have a User struct with a field Name string. The database column is user_name, not name. You add a tag to the struct: `db:"user_name"`. Now your library needs to read that string at runtime so it can generate the correct SQL. Hardcoding the mapping breaks every time the struct changes. You need to inspect the struct definition and extract the metadata attached to each field.

Struct tags are the mechanism Go uses to attach metadata to fields. They live inside backticks right after the field type. The reflect package provides the tools to read those tags while the program is running.

Think of a struct like a form. The fields are the boxes where data goes. The tags are sticky notes stuck to the boxes, telling the printer how to format them. Reflection is the ability to read the sticky notes while the form is being filled out. The notes are just strings. The compiler doesn't interpret them. Libraries like encoding/json or your custom code read the strings and act on them.

The minimal extraction

Here's the direct way to grab a tag from a specific field. You get the type descriptor, find the field by name, and parse the tag string.

package main

import (
	"fmt"
	"reflect"
)

type Config struct {
	Host string `env:"API_HOST" default:"localhost"`
	Port int    `env:"API_PORT"`
}

func main() {
	// TypeOf returns the reflection type descriptor for the value.
	// It ignores the actual data and focuses on the structure.
	t := reflect.TypeOf(Config{})

	// FieldByName looks up the field by name.
	// It returns a StructField and a bool indicating success.
	// Always check the bool; a missing field returns a zero StructField.
	field, ok := t.FieldByName("Host")
	if !ok {
		fmt.Println("field not found")
		return
	}

	// Tag.Get parses the tag string and extracts the value for the key.
	// Tags can contain multiple key:value pairs separated by spaces.
	// Get handles the splitting and returns the value for "env".
	tagValue := field.Tag.Get("env")
	fmt.Println(tagValue)
}

The output is API_HOST. The Tag.Get method does the heavy lifting. It splits the raw tag string by spaces, finds the key, and returns the value. If the key is missing, it returns an empty string.

How tags and reflection interact

Tags are strings. That's the most important fact. The compiler treats the content inside backticks as an opaque string attached to the field. It does not validate the syntax. You can write `foo:"bar" baz` and the compiler accepts it. The meaning is entirely up to the code that reads the tag.

The reflect.StructField type holds the tag as a reflect.StructTag. This type implements methods to parse the string. Get is the standard way to extract a value. It follows a convention: tags are space-separated key:"value" pairs. The value must be quoted. If you write `json:name` without quotes, Get might not parse it correctly depending on the content. Always quote tag values.

There's a subtle trap with Get. It returns an empty string if the key is missing, but it also returns an empty string if the value is explicitly empty. You can't distinguish between `json:""` and a missing json key using Get. Use Lookup if you need that distinction. Lookup returns the value and a bool, just like FieldByName.

// Lookup returns the value and a bool indicating if the key exists.
// This distinguishes between a missing key and an empty value.
val, exists := field.Tag.Lookup("json")
if !exists {
	// The key is not present in the tag.
} else if val == "" {
	// The key exists but the value is empty.
}

Convention aside: tags are strings, but the community follows strict conventions. json, xml, yaml, and db are standard keys. The json:"-" value is a universal signal meaning "ignore this field." Libraries respect this. If you write a library that reads tags, honor the - convention.

Iterating over a struct

Real code rarely checks one field. You usually need to process every field in a struct. The NumField method gives you the count, and Field(i) accesses fields by index. This is faster than repeated FieldByName calls.

package main

import (
	"fmt"
	"reflect"
)

type User struct {
	ID   int    `db:"id" json:"id"`
	Name string `db:"username" json:"name"`
	Age  int    `db:"age"`
}

// CollectDBTags extracts all "db" tags into a map.
func CollectDBTags(v interface{}) map[string]string {
	// TypeOf gets the type descriptor.
	t := reflect.TypeOf(v)

	// Handle pointers by dereferencing to the underlying type.
	// If you pass a *User, Kind is Ptr, not Struct.
	if t.Kind() == reflect.Ptr {
		t = t.Elem()
	}

	// Ensure we have a struct.
	if t.Kind() != reflect.Struct {
		return nil
	}

	result := make(map[string]string)

	// NumField returns the number of fields in the struct.
	for i := 0; i < t.NumField(); i++ {
		field := t.Field(i)

		// Skip unexported fields.
		// External packages usually shouldn't touch private fields.
		if !field.IsExported() {
			continue
		}

		// Get the db tag value.
		if dbTag := field.Tag.Get("db"); dbTag != "" {
			result[field.Name] = dbTag
		}
	}

	return result
}

func main() {
	tags := CollectDBTags(User{})
	for name, dbCol := range tags {
		fmt.Printf("%s -> %s\n", name, dbCol)
	}
}

The loop uses Field(i) for performance. FieldByName does a linear search every time. Field(i) is direct access. The IsExported check respects Go's visibility rules. Reflection can see unexported fields, but good practice is to skip them unless you're writing a tool that needs to inspect everything.

The pointer handling is crucial. If you pass &User{} to TypeOf, you get a pointer type. Calling FieldByName on a pointer type fails. You must call Elem() to get the struct type. Always check Kind() and dereference pointers if you expect a struct.

Pitfalls and compiler errors

Reflection breaks compile-time safety. The compiler can't check field names or tag keys. Errors surface at runtime.

If you call FieldByName on a type that isn't a struct, you get a panic. The compiler rejects this with reflect: call of reflect.Value.FieldByName on struct Value if you misuse Value instead of Type. If you use Type, the method returns a zero value and false. Always check the bool.

// FieldByName returns false if the field doesn't exist.
// Ignoring the bool leads to silent bugs with empty tags.
field, ok := t.FieldByName("Missing")
if !ok {
	// Handle the error.
}

If you pass a nil interface to TypeOf, you get a nil type. Calling methods on nil panics. ValueOf on nil interface also panics. Check for nil before reflecting.

Performance is the biggest pitfall. Reflection is slow. It prevents the compiler from inlining and optimizing. Every TypeOf and FieldByName call allocates and walks data structures. Don't use reflection in hot paths. If you need to read tags frequently, cache the results. Store the reflect.StructField or the extracted tag values in a map or a struct. Compute once, use many times.

Convention aside: gofmt doesn't care about tags. It formats the code, but it doesn't validate tag syntax. go vet might warn about unused variables, but it ignores tags. You're on your own for typos in tag keys. A typo in json:"nmae" won't break the build. It will just break the JSON output.

When to use reflection

Reflection is powerful but expensive. Use it when you need dynamic behavior based on type structure. Use alternatives when you can.

Use reflection when you are writing a library that must work with arbitrary user-defined structs, like a serializer, validator, or ORM. Use code generation when you have a fixed schema and need high performance; tools like stringer or mockgen generate static code at build time. Use interfaces with type assertions when you have a small, known set of types; the compiler checks the assertions and the code runs faster. Use encoding/json or encoding/xml when you just need standard serialization; don't reinvent the wheel.

Tags are strings. The compiler doesn't care what you write. Reflection is a tax. Pay it once or cache it.

Where to go next