The sticky note on your struct
You are building a Go service that talks to a frontend. The frontend expects a field called userName. Your Go struct naturally uses UserName to follow Go naming conventions. You run json.Marshal, and the output says UserName. The frontend breaks. You also have a CreatedAt timestamp that is empty when a new record is first created. The JSON output includes "CreatedAt":0, which confuses the client. You need a way to tell the JSON encoder how to translate your Go fields without renaming them in your codebase.
Struct tags solve this. They are backtick-delimited metadata strings attached to struct fields. They do not change the Go type. They do not affect memory layout. They are instructions for packages that know how to read them. The encoding/json package looks for the json key inside those backticks. If it finds it, it follows the rules. If it does not, it falls back to the exact field name.
Think of a struct tag like a customs declaration form. The form has a printed label that says Occupation. You write Software Engineer in the box. The form does not care what you write, as long as you follow the format. In Go, the format is strict. The tag string lives inside backticks. It uses a key-value syntax. The json package parses it at runtime using reflection.
Tags are instructions, not types. The compiler ignores them. Only the package that reads them cares.
How the json tag works
The json tag accepts a comma-separated list of options. The first part before any comma is the output field name. Everything after the comma is a modifier. The most common modifier is omitempty, which tells the encoder to skip the field if it holds a zero value. Another option is -, which tells the encoder to ignore the field entirely.
Here is the simplest struct that uses these rules:
type User struct {
// Maps to "name" in JSON output.
Name string `json:"name"`
// Skips the field if Email is an empty string.
Email string `json:"email,omitempty"`
// Completely ignores Age during marshaling and unmarshaling.
Age int `json:"-"`
}
When you pass this struct to json.Marshal, the encoder reads the backticks. It splits the string by commas. It takes name as the key. It sees omitempty and checks if Email is "". If it is, the field disappears from the JSON. If Age has a value, it still vanishes because of the - directive.
Go formatting tools align backticks automatically. Run gofmt or let your editor save with formatting enabled. The alignment makes long structs readable without manual spacing.
Reflection is fast enough for APIs. Don't pre-optimize serialization.
What happens under the hood
Go does not bake struct tags into the binary. They live in the program's metadata and are accessible at runtime through the reflect package. When encoding/json starts encoding a struct, it iterates over each field. It calls reflect.StructField.Tag.Get("json"). If the result is empty, it uses the field name as the JSON key. If the result contains a name, it uses that instead.
The parser handles case sensitivity carefully. During marshaling, the tag name wins. During unmarshaling, the JSON key is matched against the tag name first. If no tag exists, the JSON key is matched against the field name in a case-insensitive way. This fallback exists for convenience, but it can hide bugs. If your JSON says userName and your struct has UserName without a tag, it works. If you later add a tag json:"name", the unmarshaling suddenly fails because userName no longer matches name.
The encoding/json package only looks at exported fields. Exported means the first letter is capitalized. If you lowercase a field name, the encoder skips it regardless of tags. This is a language rule, not a JSON rule. The community accepts this design because it keeps serialization predictable. You control visibility with capitalization. You control naming with tags.
Zero values are not the same as missing values. Pick your representation deliberately.
Real-world API response struct
Production APIs rarely send flat structs. They nest data, embed shared fields, and handle optional payloads. The json tag handles all of these patterns. Embedded structs flatten their fields into the parent JSON object. Pointers change how omitempty behaves. A pointer is considered empty when it is nil. A value type is considered empty when it holds its zero value.
Here is a realistic response struct for a user profile endpoint:
type Profile struct {
// Anonymous field flattens Address into the parent JSON object.
Address
// Pointer lets omitempty distinguish between missing and empty.
Bio *string `json:"bio,omitempty"`
// Explicit key name with omitempty skips false values.
IsVerified bool `json:"is_verified,omitempty"`
}
type Address struct {
City string `json:"city"`
Country string `json:"country"`
}
When json.Marshal encounters an anonymous struct field, it treats the embedded fields as if they belong to the parent. No extra nesting appears in the JSON. The Bio field uses a pointer. If Bio is nil, omitempty removes it. If Bio points to "", it stays in the JSON as "bio":"". This distinction matters when your API needs to differentiate between not provided and explicitly cleared.
Unmarshaling follows the same tag rules but adds error handling. The encoding/json package returns an error if the JSON structure does not match the Go type. You must check that error. The community accepts the if err != nil { return err } boilerplate because it makes the unhappy path visible.
Here is how you decode a JSON payload into that struct:
func ParseProfile(data []byte) (*Profile, error) {
// Allocate a pointer to hold the decoded struct.
var p Profile
// Unmarshal attempts to map JSON keys to struct fields.
if err := json.Unmarshal(data, &p); err != nil {
// Return early on malformed JSON or type mismatch.
return nil, fmt.Errorf("parse profile: %w", err)
}
// Return the populated struct to the caller.
return &p, nil
}
The decoder writes nil to pointer fields when the JSON value is null. If your code assumes the pointer is always valid, you get a runtime panic. Always check pointers before use, or use value types if the field is required.
The compiler won't save you from a typo in a backtick string. Test your JSON contracts.
Pitfalls and silent failures
Struct tags are strings. The Go compiler does not validate their syntax. If you type json:"name,omitEmpty" instead of omitempty, the encoder silently ignores the typo and treats the whole string as the field name. Your JSON output will contain a key called name,omitEmpty. The compiler gives you no warning. You only find out when a client complains about malformed data.
Another common trap involves unmarshaling into structs with mismatched cases. The encoding/json package is case-insensitive during decoding. If your JSON contains UserName, it will match username, USERNAME, or UserName in your struct. This flexibility breaks when you add explicit tags. Once you add json:"name", the decoder stops doing case-insensitive fallback and requires an exact match. If your payload still says UserName, unmarshaling fails with json: cannot unmarshal object into Go struct field User.name due to mismatched key.
Unknown JSON keys are silently ignored by default. If your client sends extra fields that your struct does not define, json.Unmarshal discards them. This lenient behavior prevents breaking when the API evolves, but it also hides bugs during development. If you want strict validation, you can add a field of type map[string]json.RawMessage to capture unknown keys, or use a third-party validator.
Sometimes you receive JSON but only need one field. You can discard the rest using the underscore. result, _ := ... says you considered the second return value and chose to drop it. Use it sparingly with errors, but it works perfectly for unmarshaling into a struct where you only care about a subset of fields.
Match the wire format, not the database schema.
When to use which approach
Use a json:"name" tag when the wire format uses snake_case or camelCase and your Go struct uses PascalCase. Use the omitempty option when a field is optional and you want to reduce payload size by skipping zero values. Use a pointer type alongside omitempty when you need to distinguish between a missing field and a field that explicitly holds a zero value. Use the - directive when a struct field exists for internal logic but must never appear in JSON output. Use an anonymous embedded struct when you want to flatten nested data into a single JSON object without writing manual copy logic. Use plain exported fields without tags when the Go field name already matches the expected JSON key exactly. Use a map[string]json.RawMessage field when you need to capture and validate unknown JSON keys. Use json.Unmarshal with explicit error wrapping when you are building a public API that must fail loudly on malformed input.
Context is plumbing. Run it through every long-lived call site.