Struct Tags for JSON, DB & Validation
Production structs often carry three tag namespaces on one field: json for APIs, db or ORM-specific keys for storage, and validate for input rules.
Consistent naming across tags keeps serializers, SQL scanners, and validators aligned without duplicate DTO layers.
Summary
Struct tags are string metadata attached to fields, read via reflection at runtime.
Each library parses its own key (json, db, validate, xml, yaml, etc.).
A single struct can serve HTTP and database layers when tags agree on field meaning but differ on wire/column names.
Recipe
Quick-reference recipe card - copy-paste ready.
type User struct {
ID int64 `json:"id" db:"id" validate:"required"`
Email string `json:"email" db:"email" validate:"required,email"`
Nickname *string `json:"nickname,omitempty" db:"nickname" validate:"omitempty,min=2,max=32"`
CreatedAt time.Time `json:"created_at" db:"created_at" validate:"-"`
Internal string `json:"-" db:"internal_note" validate:"-"`
}When to reach for this:
- One domain model maps to JSON responses and SQL rows.
- Validators run immediately after JSON decode in handlers.
- You want grep-friendly field definitions in code review.
Working Example
package main
import (
"encoding/json"
"fmt"
"time"
)
type Product struct {
SKU string `json:"sku" db:"sku" validate:"required,alphanum"`
Title string `json:"title" db:"title" validate:"required,min=3,max=120"`
PriceCents int64 `json:"price_cents" db:"price_cents" validate:"gte=0"`
Active bool `json:"active" db:"is_active" validate:"-"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at" validate:"-"`
}
func main() {
p := Product{
SKU: "ABC123",
Title: "Wrench",
PriceCents: 1299,
Active: true,
UpdatedAt: time.Now().UTC(),
}
b, _ := json.Marshal(p)
fmt.Println(string(b))
// db tag is ignored by encoding/json; sqlx/jmoiron/sql would use db:"sku" on Scan
// validate tags are consumed by go-playground/validator after Unmarshal
}What this demonstrates:
- JSON uses snake_case keys while Go fields stay idiomatic PascalCase.
dbtags name columns forsqlx,scany, or similar scanners.validatetags express rules separate from serialization.json:"-"hides fields from API output whiledbmay still persist them.
Deep Dive
Tag Grammar
Tags are backtick-quoted strings: `key:"value" other:"value"`.
Commas inside a value are usually options: validate:"omitempty,min=1".
Unknown keys are ignored by each library.
Common Tag Keys
| Key | Consumer | Purpose |
|---|---|---|
json | encoding/json | Wire name, omitempty, - |
db | sqlx, scany | Column name |
validate | go-playground/validator | Input rules |
xml | encoding/xml | Element/attr name |
yaml | yaml.v3 | Config files |
form | gin/echo binders | HTML forms |
ORMs like GORM use their own struct tags (gorm:"column:...") - pick one persistence style per project.
Naming Conventions
| Layer | Convention | Example field CreatedAt |
|---|---|---|
| Go field | PascalCase | CreatedAt |
| JSON | snake_case (common) | created_at |
| SQL column | snake_case | created_at |
Document exceptions (legacy columns, camelCase JSON) in ADRs.
Multi-DTO vs Single Struct
| Approach | Pros | Cons |
|---|---|---|
| Single tagged struct | Less boilerplate | Couples API to schema |
| Separate API/DB models | Clear boundaries | More mapping code |
| Embedded base + wrappers | Shared IDs/timestamps | Tag collisions if careless |
Start single-struct for small services; split when API versioning diverges from tables.
Go Notes
// Lint: keep json and db names aligned unless legacy schema differs
// validate:"-" skips validation for server-set timestampsgolangci-lint tag checkers (when enabled) flag mismatched json field names vs Go field names.
Gotchas
- json ignored on unexported fields - Tags on lowercase fields do nothing for
encoding/json. Fix: export API fields or use custom marshalers. - validate never runs automatically - Tags are inert until you call
validator.Struct. Fix: validate in handlers afterUnmarshal. - db tags without the right library -
database/sqlalone ignoresdbtags. Fix: use sqlx/scany or explicit column lists. - omitempty on pointers vs values - Zero ints always encode unless
omitempty; nil pointers omit. Fix: model optional numbers as pointers when0is meaningful. - Tag drift on migrations - Renaming a column without updating
dbtags causes silent NULL scans. Fix: integration tests for repositories. - Conflicting json keys - Embedded structs can produce duplicate JSON keys. Fix: rename tags or flatten with custom marshalers.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Single multi-tagged struct | CRUD services with aligned shapes | Public API very different from schema |
| Separate DTOs per layer | Versioned APIs, complex joins | Tiny scripts |
| Code generation (sqlc, buf) | Large schemas | One-off prototypes |
map[string]any | Dynamic admin tools | Stable contracts |
FAQs
Must JSON and db names match?
They often do for developer ergonomics.
Legacy databases may force different db tags - document those fields.
Where do validate tags run?
After JSON decode in HTTP handlers or after form bind.
Not during json.Marshal unless you add a custom pipeline.
Can I generate tags from SQL?
Tools like sqlc generate structs with tags from queries.
Hand-written models need manual discipline.
What about bson tags for Mongo?
Use bson tags with the official driver structs.
Same multi-tag pattern applies.
How do gin form tags interact?
form:"email" supports query and multipart binding.
Keep json and form aligned when the same struct serves both.
Should internal fields use json:"-"?
Yes for secrets and join-only columns.
Ensure logs use a redacted DTO if the full struct is printed.
How do protobuf Go structs relate?
Protobuf codegen uses struct fields generated from .proto - not json tags.
Gateways may add JSON names separately.
Can tags be linted in CI?
Enable tag alignment linters in golangci-lint where available.
Code review checklists catch drift too.
What validate tag for optional email?
validate:"omitempty,email" skips empty strings but validates present values.
How do I tag embedded structs?
Promoted fields inherit tags from the embedded type.
Outer overrides win on name conflicts - test JSON output.
Related
- Serialization Basics - json tag fundamentals
- encoding/json Custom Marshaling - when tags are not enough
- Validation with go-playground/validator - validate tag reference
- Protocol Buffers Schema Design - protobuf as alternate contract
- Serialization Best Practices - tag review checklist
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).