Lesson 3: Struct Tags, JSON & Protobuf
Why Tags Matter in Cloud-Native Go
Look at a Kubernetes Custom Resource Definition:
type MyApp struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec MyAppSpec `json:"spec,omitempty"`
Status MyAppStatus `json:"status,omitempty"`
}
type MyAppSpec struct {
Replicas *int32 `json:"replicas,omitempty"`
Image string `json:"image"`
Resources string `json:"resources,omitempty" validate:"required"`
}
Those backtick strings — json:"replicas,omitempty" — are struct tags. They're not comments. They're not magic. They're metadata consumed by Go's reflect package at runtime to drive serialization, validation, and code generation.
Every cloud-native Go project uses them. Understanding tags means you can write Kubernetes operators, parse configs, generate protobuf code, and bind environment variables — all with the same mechanism.
Master struct tags for JSON encoding/decoding, understand protobuf tag patterns, and know how to write custom JSON marshalers.
Anatomy of a Struct Tag
type Server struct {
// ↓ key ↓ value with options
Host string `json:"host"`
Port int `json:"port,omitempty"`
TLS bool `json:"tls" env:"SERVER_TLS"`
// ↑ multiple tags separated by space
}
A struct tag is a raw string literal (backticks) containing space-separated key:"value" pairs.
The format is convention — not enforced by the compiler. Libraries use reflect.StructTag to parse them:
import "reflect"
t := reflect.TypeOf(Server{})
field, _ := t.FieldByName("Port")
tag := field.Tag
fmt.Println(tag.Get("json")) // "port,omitempty"
val, ok := tag.Lookup("json") // "port,omitempty", true
val, ok := tag.Lookup("xml") // "", false (tag not present)
Tags are the user-facing side of Go's reflection system. You'll revisit this in Lesson 0010 when we cover reflect in depth. For now, think of tags as "configuration for reflection-based libraries."
JSON Marshal & Unmarshal
The most common use of struct tags: mapping Go structs to JSON and back.
Basic Marshal
type Person struct {
Name string `json:"name"`
Age int `json:"age"`
}
data, _ := json.Marshal(Person{"Alice", 30})
fmt.Println(string(data)) // {"name":"Alice","age":30}
Basic Unmarshal
input := []byte(`{"name":"Bob","age":25}`)
var p Person
json.Unmarshal(input, &p)
fmt.Printf("%+v\n", p) // {Name:Bob Age:25}
Renaming Fields
type Config struct {
MaxConnections int `json:"max_connections"`
// Go field: MaxConnections → JSON key: "max_connections"
}
Skipping Fields
type User struct {
Name string `json:"name"`
Password string `json:"-"` // never serialized
internal string // unexported — also never serialized
}
Embedded Structs (inline)
type Base struct {
ID string `json:"id"`
Kind string `json:"kind"`
}
type Pod struct {
Base // embedded — fields promoted to same JSON level
Spec PodSpec `json:"spec"`
}
// If Base has json:",inline", its fields are inlined flat.
// Without ",inline", they're nested under "Base":{}.
Custom JSON Marshal/Unmarshal
Implement these interfaces (from Lesson 0001!) to control JSON behavior:
type Marshaler interface {
MarshalJSON() ([]byte, error)
}
type Unmarshaler interface {
UnmarshalJSON([]byte) error
}
Real example: custom duration format in Kubernetes:
type Duration struct {
time.Duration
}
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(d.Duration.String()) // "1h30m"
}
func (d *Duration) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
dur, err := time.ParseDuration(s)
if err != nil {
return err
}
d.Duration = dur
return nil
}
json.Marshaler and json.Unmarshaler are interfaces. If your type has the methods, encoding/json uses them automatically — implicit satisfaction in action.
The omitempty Trap
omitempty omits a field from JSON output when it has its zero value:
type App struct {
Name string `json:"name,omitempty"`
Port int `json:"port,omitempty"`
Debug bool `json:"debug,omitempty"`
Config Settings `json:"config,omitempty"`
}
app := App{Name: "myapp"}
data, _ := json.Marshal(app)
fmt.Println(string(data)) // {"name":"myapp","config":{}}
Config was NOT omitted even though you didn't set it! With omitempty, a struct is only omitted when it's the zero value — and the zero value of a struct is Settings{}, which is an empty struct, not nil. encoding/json treats Settings{} as "has a value" and serializes it as {}.
Solution: use a pointer if you want omit-on-nil:
type App struct {
Config *Settings `json:"config,omitempty"` // nil → omitted
}
Zero value reference:
TypeZero value (omitted by omitempty)
bool``false
int, float``0
string``""
pointer, interface, slice, map, chan, funcnil
structStruct{} (never omitted)
Protobuf Struct Tags
Protobuf tags are generated by protoc, not hand-written. You write a .proto file, and protoc-gen-go produces Go structs with tags:
// Example .proto file:
// message Server {
// string host = 1;
// int32 port = 2;
// }
// Generated Go code:
type Server struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
Port int32 `protobuf:"varint,2,opt,name=port,proto3" json:"port,omitempty"`
}
Notice the generated code includes both protobuf and json tags. The protobuf tag drives protobuf serialization; the json tag drives JSON output (gRPC-Gateway, etcd API, Kubernetes API).
Protobuf Tag Format
ComponentMeaningExample
bytes, varintWire encoding typebytes for strings
1, 2Field number (from .proto)1
opt, repOptional / repeatedopt
name=hostProtobuf field namename=host
proto3Syntax versionproto3
You rarely write protobuf tags by hand. But you need to read them to understand generated code. The Kubernetes API, etcd, and gRPC services all use protobuf-generated Go structs.
Protobuf + JSON in Kubernetes
Kubernetes stores resources as protobuf in etcd, but exposes them as JSON through the API server. The generated structs carry both tags:
// Kubernetes PodSpec (simplified, generated from .proto)
type PodSpec struct {
Containers []Container `protobuf:"bytes,2,rep,name=containers" json:"containers"`
NodeName string `protobuf:"bytes,4,opt,name=nodeName" json:"nodeName,omitempty"`
// ...
}
Other Real-World Tag Uses
YAML (gopkg.in/yaml.v3)
type Config struct {
ServerPort int `yaml:"server_port"`
LogLevel string `yaml:"log_level"`
}
// Kubernetes operators use this for CRD specs
Validation (go-playground/validator)
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=2,max=50"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"gte=0,lte=130"`
}
Environment Variables (kelseyhightower/envconfig)
type Spec struct {
Port int `envconfig:"SERVER_PORT" default:"8080"`
DBUrl string `envconfig:"DATABASE_URL" required:"true"`
Workers int `envconfig:"NUM_WORKERS" default:"4"`
}
SQL Mapping (sqlx)
type User struct {
ID int `db:"id"`
Name string `db:"name"`
CreatedAt time.Time `db:"created_at"`
}
Practice
Define a struct Container with fields: Name string, Image string, Port int, and Env map[string]string. Add JSON tags so the JSON keys are lowercase: name, image, port, env.
In main(): create a Container, marshal it to JSON (print the result), then unmarshal the JSON back into a new Container (print the struct). Verify round-trip fidelity.
Define two versions of a struct:
ConfigV1withTimeout Durationas a plain struct field:json:"timeout,omitempty"ConfigV2withTimeout *Durationas a pointer:json:"timeout,omitempty"Marshal both with zero Timeout. Observe: V1 still outputs"timeout":{}; V2 omits it.
Define a type LogLevel int with constants: LogDebug=0, LogInfo=1, LogWarn=2, LogError=3.
Implement MarshalJSON and UnmarshalJSON so that:
- 0 marshals to
"debug", 1 to"info", 2 to"warn", 3 to"error" - Unmarshaling from those strings produces the correct constant
- Unknown strings produce an error
Test with a struct that has a
LogLevelfield, marshal/unmarshal round-trip.
Quick Check
What's Next?
You now understand how Go maps data to JSON, protobuf, and other formats. Next up: Testing — table-driven tests, mocks via interfaces, testing.T, subtests, and how Kubernetes controllers are tested with fake clients.
Ask Questions
Confused about omitempty behavior with structs? Want to see more protobuf examples? Ask.
1. Q1: What does json:"-" do?
2. Q2: With the struct below, what does json.Marshal output for App{Name: "test"}?
3. Q3: To properly omit a struct field with omitempty, you should: