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.
Search across all documentation pages
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.
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.
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:
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:
db tags name columns for sqlx, scany, or similar scanners.validate tags express rules separate from serialization.json:"-" hides fields from API output while db may still persist them.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.
| 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.
| 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.
| 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.
// 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.
encoding/json. Fix: export API fields or use custom marshalers.validator.Struct. Fix: validate in handlers after Unmarshal.database/sql alone ignores db tags. Fix: use sqlx/scany or explicit column lists.omitempty; nil pointers omit. Fix: model optional numbers as pointers when 0 is meaningful.db tags causes silent NULL scans. Fix: integration tests for repositories.| 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 |
They often do for developer ergonomics.
Legacy databases may force different db tags - document those fields.
After JSON decode in HTTP handlers or after form bind.
Not during json.Marshal unless you add a custom pipeline.
Tools like sqlc generate structs with tags from queries.
Hand-written models need manual discipline.
Use bson tags with the official driver structs.
Same multi-tag pattern applies.
form:"email" supports query and multipart binding.
Keep json and form aligned when the same struct serves both.
Yes for secrets and join-only columns.
Ensure logs use a redacted DTO if the full struct is printed.
Protobuf codegen uses struct fields generated from .proto - not json tags.
Gateways may add JSON names separately.
Enable tag alignment linters in golangci-lint where available.
Code review checklists catch drift too.
validate:"omitempty,email" skips empty strings but validates present values.
Promoted fields inherit tags from the embedded type.
Outer overrides win on name conflicts - test JSON output.
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).
Reviewed by Chris St. John·Last updated Jul 18, 2026