Right, so you’ve got a struct. It’s a nice, tidy little struct. You’ve probably adorned it with some backticked decorations, like json:"name" or db:"user_id". These struct tags are fantastic for configuration, but they’re just… there. They’re a part of the comment, essentially invisible to your running code. Until you bring in the big guns: the reflect package.

Think of reflect as your all-access pass to the type system’s backstage. It lets you interrogate a type at runtime to figure out what it’s made of. And reading struct tags is one of its most common and practical party tricks. It’s how libraries like encoding/json know how to map your UserName field to a "user_name" key in JSON without you having to write a single line of parsing logic. Let’s break down how you can do that yourself.

The Basic Incantation

The process isn’t magic, but it is a bit ritualistic. You need to get the type of your object, get its underlying struct type (if it’s a pointer, you have to dereference it first), then iterate through its fields. For each field, you ask for its Tag.

package main

import (
    "fmt"
    "reflect"
)

type User struct {
    ID        int    `json:"id" db:"user_id"`
    Username  string `json:"username" db:"user_name"`
    Password  string `json:"-"`                  // Ignore this in JSON
    CreatedAt int64  `json:"created_at,omitempty"` // Omit if empty
}

func main() {
    u := User{ID: 42, Username: "arthurdent"}

    // Step 1: Get the "Value" of the variable
    val := reflect.ValueOf(u)

    // Step 2: Get the type from the value (a reflect.Type)
    typ := val.Type()

    // Step 3: Iterate over the fields of the struct
    for i := 0; i < typ.NumField(); i++ {
        field := typ.Field(i) // reflect.StructField
        tag := field.Tag     // reflect.StructTag (which is basically a string)
        fmt.Printf("Field: %s\tTag: %s\n", field.Name, tag)
    }
}

Running this will give you:

Field: ID        Tag: json:"id" db:"user_id"
Field: Username  Tag: json:"username" db:"user_name"
Field: Password  Tag: json:"-"
Field: CreatedAt Tag: json:"created_at,omitempty"

Cool, we got the raw tag string. But we don’t want the whole thing; we usually want the value for a specific key, like json.

Actually Parsing the Tag Value

A reflect.StructTag is, under the hood, just a string with a fancy method bolted onto it: Get(key string) string. This method is the real workhorse. It understands the convention of spaces separating key-value pairs and will return the value for a specific key.

Let’s modify our loop to be more useful:

for i := 0; i < typ.NumField(); i++ {
    field := typ.Field(i)
    jsonTag := field.Tag.Get("json")
    dbTag := field.Tag.Get("db")
    fmt.Printf("Field: %s\tjson: %s\t\tdb: %s\n", field.Name, jsonTag, dbTag)
}

Now the output becomes genuinely useful:

Field: ID        json: id          db: user_id
Field: Username  json: username    db: user_name
Field: Password  json: -           db:
Field: CreatedAt json: created_at,omitempty db:

Notice what happened? For the Password field, the db tag doesn’t exist, so .Get returns an empty string. This is the correct way to check for the presence of a tag. You never check the raw tag string yourself.

Handling Pointers and Other Nasties

The code above works great for a concrete User struct. But what if you have *User? If you try to run reflect.ValueOf(&u).Type().NumField(), you’ll get a panic faster than you can say “nil pointer dereference.” This is because the type of a *User is a pointer, and a pointer has no fields.

You have to handle this. It’s a classic reflect footgun. The solution is to use Value.Elem() to dereference the pointer if it’s a pointer or interface, which gives you the underlying value.

func printTags(obj interface{}) {
    val := reflect.ValueOf(obj)
    // If it's a pointer, get the value it points to
    if val.Kind() == reflect.Ptr {
        val = val.Elem()
    }
    // Now, get the type of the underlying value
    typ := val.Type()

    // Make sure we're actually dealing with a struct now.
    // Trying to get fields from a slice or int would also panic.
    if typ.Kind() != reflect.Struct {
        fmt.Printf("Expected a struct, got %s\n", typ.Kind())
        return
    }

    for i := 0; i < typ.NumField(); i++ {
        field := typ.Field(i)
        fmt.Printf("%s: %s\n", field.Name, field.Tag.Get("json"))
    }
}

This function is now much more robust. It can handle User, *User, and even a nil *User (though val.Elem() will panic on a nil pointer, so you’d need an extra check for that—see what I mean about footguns?).

The Subtleties and “Why” Behind the Convention

You’ll see that the json tag for CreatedAt is "created_at,omitempty". The Get method returns the entire value: "created_at,omitempty". It’s up to the library using the tag (like encoding/json) to split this string on the comma and interpret the options. The reflect package itself doesn’t care about the content of the tag; it just provides the mechanism to read it. The meaning is defined by whatever library requested it.

This is why the convention is so powerful. It’s a generic key-value store slapped onto a struct field. The reflect package provides the standard way to read it, and then the consuming code is responsible for parsing the value according to its own rules. It’s a perfect example of separation of concerns.

So, the next time you slap a validate:"required,email" tag on a field, remember: you’re not using some magic language feature. You’re just leaving a note for some other code that knows how to use reflect to find it. And now, so do you.