Why I Ditched Struct Tags for Schema-Driven JSON Validation in Go
Three years ago, I inherited a microservices platform handling millions of marketplace transactions. Everything worked fine until the day we had to accept third-party JSON payloads we didn't control.
That's when struct tags broke.
The Struct Tag Problem
When you're building an API that owns the request shape, struct tags are great:
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=2,max=100"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"required,min=18,max=120"`
Phone string `json:"phone" validate:"omitempty,phone"`
}
func CreateUser(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
json.NewDecoder(r.Body).Decode(&req)
// Validation happens via struct tags + reflection
if err := validate.Struct(req); err != nil {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
}
// ... continue
}
But then you hit these walls:
1. Rules Locked at Compile Time
Want to change the minimum age from 18 to 21? Redeploy.
Want to make phone optional only on weekends? That's not a struct tag problem—that's an architecture problem.
// Can't do this dynamically
// Age validation is baked into the struct
type User struct {
Age int `validate:"min=18"` // Hardcoded at compile time
}
2. Every New Field = New Struct
We worked with a marketplace that sent different payloads for sellers vs. buyers. So we built:
type BuyerPayload struct { /* 15 fields */ }
type SellerPayload struct { /* 25 fields */ }
type AdminPayload struct { /* 40 fields */ }
// And when they change their API? Rebuild all three.
Three structs. Three deploy cycles. Three reasons for a bug.
3. Nested Validation Gets Ugly
A seller payload with nested address, tax_info, and payout_methods:
type SellerPayload struct {
Name string `validate:"required,min=2"`
Address struct {
Street string `validate:"required"`
City string `validate:"required"`
Zip string `validate:"required,regex=^\d{5}$"`
} `validate:"required"`
TaxInfo struct {
SSN string `validate:"required,ssn"`
// ... five more fields
} `validate:"required"`
}
The moment you have 3-4 levels of nesting, your struct becomes a wall of text. And when validation logic is conditional (validate SSN only if TaxInfo.Type == "individual"), struct tags can't express it.
4. Panics on Unexpected Types
The worst part: reflection-heavy validators panic on types they don't expect.
type Order struct {
Items []Item `validate:"required"`
}
// If a malicious request sends {"items": "not an array"},
// some validators panic instead of returning a typed error.
// That takes down your entire handler.
The Real Problem
Struct tags assume your JSON is well-behaved. They work if:
- You control the shape
- Rules are static
- Structure is known at compile time
- You can afford a redeploy to change a rule
None of that was true for us.
The Schema-Driven Alternative
Two years into shipping the marketplace platform, I realized: what if rules were data, not code?
Instead of embedding validation in a struct, write a schema file:
{
"version": "2.0",
"fields": [
{
"target": "user.profile.name",
"type": "string",
"required": true,
"validate": [
{ "rule": "minLength", "args": { "min": 2 } },
{ "rule": "maxLength", "args": { "max": 100 } }
]
},
{
"target": "user.profile.email",
"required": true,
"validate": [
{ "rule": "email" }
]
},
{
"target": "user.profile.age",
"validate": [
{ "rule": "min", "args": { "min": 18 } },
{ "rule": "max", "args": { "max": 120 } }
]
}
]
}
Now your Go code is dead simple:
func CreateUser(w http.ResponseWriter, r *http.Request) {
var data map[string]any
json.NewDecoder(r.Body).Decode(&data)
if err := schema.Validate(data); err != nil {
var ve *schematics.ValidationErrors
if errors.As(err, &ve) {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{
"errors": ve.Strings("en", "%target: %message"),
})
}
}
// ... continue
}
What changed:
1. Rules Are Now Data
Update the schema file, reload it at startup. No redeploy needed for rule changes.
# Monday: min age is 18
# Wednesday: client needs it to be 21
# Just edit the JSON, restart the service (or hot-reload via config system)
2. One Schema, Many Shapes
Instead of BuyerPayload, SellerPayload, AdminPayload, one schema can validate all three. Use wildcards or regex to match different structures:
{
"fields": [
{
"target": "*.profile.email", // Match email at any nesting level
"validate": [{ "rule": "email" }]
},
{
"targetRegex": true,
"target": "^address\\.(street|city|zip)$", // Regex targeting
"required": true
}
]
}
3. Conditional Validation
Validate SSN only if the payload declares type="individual":
{
"target": "tax_info.ssn",
"validate": [{ "rule": "ssn" }],
"when": [
{
"condition": "fieldEquals",
"args": { "field": "tax_info.type", "value": "individual" }
}
]
}
4. Typed Errors, Never Panics
Every validation error is a ValidationError with the target, rule, value, and localized message:
var ve *schematics.ValidationErrors
if errors.As(err, &ve) {
for _, e := range ve.Errors {
fmt.Printf("Field: %s, Rule: %s, Value: %v\n",
e.Target, e.Rule, e.Value)
fmt.Printf("Message: %s\n", e.Message("en"))
}
}
No panics. No reflection surprises. Ever.
How It Works: The Pipeline
Here's what happens under the hood:
Input JSON
↓
[Flatten] → user.profile.name, user.profile.age, tags.0, tags.1
↓
[Match] → Map each flattened key to schema fields (literal, wildcard, regex)
↓
[Validate] → Run validators in order. First failure stops that field.
↓
[Operate] → (Optional) Transform: trim, capitalize, math, reshape
↓
Output: Valid data or ValidationErrors
Example:
data := map[string]any{
"user": map[string]any{
"profile": map[string]any{
"name": " ada ", // Extra spaces
"age": 200, // Invalid
"email": "ada@rvbc.io",
},
"tags": []string{"go", "rust"},
},
}
s := schematics.New()
s.LoadFile("schema.json")
if err := s.Validate(data); err != nil {
var ve *schematics.ValidationErrors
if errors.As(err, &ve) {
for _, msg := range ve.Strings("en", "%target: %message") {
fmt.Println(msg)
}
// Output:
// user.profile.age: must be between 0 and 120
}
}
// Optionally transform
transformed, _ := s.Operate(data)
// transformed["user"]["profile"]["name"] == "Ada" (trimmed + capitalized)
Real-World Win: The CSV Importer
This is where schema-driven validation really shone for us.
We built a bulk CSV importer for seller onboarding. Each row is a seller record. Each row can have a different structure (some have tax info, some don't; some have multiple addresses, some one).
With struct tags, we'd need:
// Option 1: Loose struct that accepts everything
type SellerRow struct {
Data map[string]any // Lose all type safety
}
// Option 2: Strict struct that mirrors CSV
type SellerRow struct {
Name string
Email string
TaxInfoSSN string // Sparse columns
Address1 string
Address2 string
// ... 20 optional fields
}
Neither works. We went with schema-driven, loaded a schema that could handle 20+ optional fields with conditional validation:
{
"fields": [
{
"target": "name",
"required": true,
"validate": [{ "rule": "minLength", "args": { "min": 2 } }]
},
{
"target": "email",
"required": true,
"validate": [{ "rule": "email" }]
},
{
"target": "tax_info.ssn",
"validate": [{ "rule": "ssn" }],
"when": [
{
"condition": "fieldPresent",
"args": { "field": "tax_info" }
}
]
}
]
}
One schema. Hundreds of rows. Partial data OK. Conditional rules. No structs needed.
The Trade-Offs
I won't pretend this is a silver bullet.
Struct tags are still better for:
- Small, tightly-typed internal APIs (you control the shape, rules are stable)
- When you need compile-time guarantees
- Microservices with one consumer
Schema-driven is better for:
- Third-party integrations (you don't control the shape)
- CSV/bulk imports (sparse, variable structure)
- Rules that change without redeploy
- Multiple payload shapes
- Marketplace/multi-tenant platforms
Getting Started
I open-sourced this as json-schematics-v2. Zero dependencies, standard library only, typed errors, never panics.
Try it now:
go get github.com/ashbeelghouri/json-schematics-v2@latest
Interactive playground:
https://jsonschematics.ashbeelghouri.com/playground
Paste a JSON document, paste a schema, see validation errors live.
Documentation:
https://jsonschematics.ashbeelghouri.com/docs
Code example:
package main
import (
"fmt"
schematics "github.com/ashbeelghouri/json-schematics-v2"
)
func main() {
s := schematics.New()
s.LoadFile("schema.json")
data := map[string]any{
"user": map[string]any{
"profile": map[string]any{"name": "ada", "age": 30},
},
}
if err := s.Validate(data); err != nil {
fmt.Println("Validation failed:", err)
} else {
fmt.Println("Valid!")
}
}
Why Zero Dependencies Matter
Most Go validators pull in external packages. json-schematics-v2 is standard library only:
- No dependency hell (you control versions)
- No surprise breaking changes in gojsonschema
- Smaller binary
- Auditable surface (read the source)
- No type panics from reflection
What's Next
I've been shipping this in production for:
- API validation (headers, query, body)
- CSV imports
- Data migrations
- Form validation
- Event payloads
The library is battle-tested. But I'm always looking for feedback: edge cases, performance issues, feature requests.
Call to Action
- Try the playground: https://jsonschematics.ashbeelghouri.com/playground
- Star the repo: https://github.com/ashbeelghouri/json-schematics-v2
- Share feedback: Open an issue or DM me on GitHub
If you're tired of struct tag limitations, or shipping APIs that need to handle shapes you don't control, this might be the tool you've been looking for.
Top comments (3)
I like the idea but the thing I have against it (the same as with the json tags):
I think I will have a validation written in code, so you can't make any typos.
What happens if "target": "name" is typoed into "target": "mane",
or "tagret": "name"
does it find out during "compilation" ?
Excellent catch. You've identified the exact weakness of schema-driven validation, and you're right to push back.
I've just added
ValidateSchema()to address this. Here's how it works:The Problem You Identified
With struct tags, the compiler catches this. With schemas, it silently doesn't validate anything. Your code keeps running with invalid data slipping through.
The Solution: Schema Validation at Load Time
I added a method that catches typos and misconfigurations before validation runs:
What ValidateSchema() Catches
1. Typos in target names
2. Unregistered rules
3. Broken regex patterns
4. Unused fields in schema
How to Use It
In Your Tests (Recommended)
Run this in CI. If someone typos "target": "mane", this test fails immediately—before it goes to production.
In Your Startup Code (For Safety)
The Trade-Off (Honest Take)
Struct Tags:
Schemas + ValidateSchema():
The key difference: you shift left to test time instead of compile time. But it catches the same problems, just earlier in your development cycle instead of in production.
When ValidateSchema() Saves You
Scenario 1: You update your schema file to add a new rule
Your tests catch it:
Scenario 2: Your API changes shape, schema becomes stale
ValidateSchema(sampleData) catches it:
Try It Now
Just updated the repo: github.com/ashbeelghouri/json-sche...
Add a test to your suite:
Run it:
go test -vThe Bigger Picture
Your comment actually highlights why schema-driven validation requires discipline:
Do this, and you get the benefits of schemas without losing the safety of struct tags.
Thank you for pushing on this. This was a genuine gap, and ValidateSchema() makes the library much safer. Community feedback like yours is why open source gets better.
Let me know if this solves your concern, or if there are other edge cases you'd like me to handle.
I'm glad you did something about it immediately. Because now this makes much more sense. I have about the same idea in my own private solution ;-) I can see if I can swap it out with yours (after my holidays).