Validation, migrate & sqlc
Three focused libraries cover common data-layer edges in Go services: go-playground/validator for struct tags, golang-migrate for schema versions, and sqlc for type-safe SQL codegen.
They complement database/sql without imposing a full ORM.
Recipe
Quick-reference recipe card - copy-paste ready.
import "github.com/go-playground/validator/v10"
var validate = validator.New()
type CreateUser struct {
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"gte=18,lte=120"`
}
func check(in CreateUser) error {
return validate.Struct(in)
}migrate -path ./migrations -database "${DATABASE_URL}" up
sqlc generateWhen to reach for this:
- HTTP handlers decode JSON DTOs that need field-level validation before domain logic
- Schema changes ship in versioned SQL files applied in CI and deploy jobs
- You want compile-time typed query methods instead of stringly
QueryRowcalls - Teams prefer explicit SQL over ORM-generated queries
Working Example
package api
import (
"context"
"encoding/json"
"net/http"
"github.com/go-playground/validator/v10"
)
var validate = validator.New()
type createUserRequest struct {
Email string `json:"email" validate:"required,email"`
Name string `json:"name" validate:"required,min=1,max=128"`
}
func createUser(w http.ResponseWriter, r *http.Request) {
var in createUserRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if err := validate.Struct(in); err != nil {
http.Error(w, formatValidation(err), http.StatusBadRequest)
return
}
// call service with validated DTO
w.WriteHeader(http.StatusCreated)
}
func formatValidation(err error) string {
// map validator.ValidationErrors to field-scoped JSON in production
return err.Error()
}-- migrations/000001_create_users.up.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL
);# sqlc.yaml
version: "2"
sql:
- engine: "postgresql"
queries: "queries/"
schema: "migrations/"
gen:
go:
package: "db"
out: "internal/db"What this demonstrates:
- Validator runs after JSON decode, before service layer
- Migrations live in versioned SQL files, not in app startup
- sqlc reads schema and query files to generate Go types and methods
- Each tool owns one job; domain rules still live in services
Deep Dive
How It Works
- validator uses struct tags and reflection; register custom validations on a shared
validator.Validateinstance. - golang-migrate tracks applied versions in a database table and runs
up/downSQL in order. - sqlc parses SQL queries with named parameters and emits Go structs matching columns.
- Run migrations before new code serves traffic; run
sqlc generatewhen queries or schema change.
Validation Tags (common)
| Tag | Meaning |
|---|---|
required | Non-zero value |
email | RFC-ish email format |
uuid | UUID string |
gte=0 / lte=100 | Numeric bounds |
oneof=red green | Enum strings |
Migration Discipline
| Practice | Why |
|---|---|
| One change per migration file | Easier rollback and review |
| Expand-contract for zero downtime | Add column nullable before backfill |
| Run in CI against ephemeral DB | Catch SQL syntax early |
| Never from request handlers | Avoid race with deploy jobs |
sqlc Workflow
- Author SQL in
queries/users.sqlwith-- name: GetUser :oneannotations. - Point sqlc at migration schema for column types.
- Commit generated
internal/dbpackage or regenerate in CI. - Repositories call generated methods; hand-write transactions in Go.
Go Notes
// Gin/Echo binding can wrap validator - still keep DTOs at the edge
// database/sql remains the runtime driver; sqlc does not replace pgx pool tuning- Map
validator.ValidationErrorsto problem+json for API clients. - Keep validation tags on transport DTOs, not domain entities, to avoid tag leakage inward.
Gotchas
- Validating domain invariants with tags only - tags check shape, not "user owns order". Fix: validator at edge, business rules in services.
- Running migrations on app boot - multiple replicas race; failed mid-migration is hard to debug. Fix: Kubernetes Job or pipeline step before rollout.
- sqlc schema drift - queries reference columns not in migration folder. Fix: single source of truth for schema paths in
sqlc.yaml. - Shared global validator - custom tag registrations mutate global state in tests. Fix:
validator.New()per package or test helper witht.Parallelcare. - Down migrations skipped in prod - teams run
uponly; brokendownhides until emergency. Fix: testdown 1in CI on disposable DBs. - SQL injection in sqlc - sqlc parameterizes; dynamic table names in string concat still unsafe. Fix: static queries only; dynamic filters via whitelisted builders.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Hand-rolled validation | Few fields, no tags | Large OpenAPI-generated DTOs |
| goose migrations | Team prefers Go migration files | You already standardized on migrate CLI |
| GORM / ent ORM | Rapid CRUD prototypes | You need tuned SQL and explicit queries |
| pgx without sqlc | Tiny query count | Dozens of queries drift from schema |
FAQs
validator with Gin or Echo?
Wire framework validators to the same validator.Validate instance; keep DTO structs shared across frameworks.
migrate or goose?
Both work; pick one per org and document CLI in deploy runbooks - databases section compares them in depth.
Does sqlc replace an ORM?
It replaces stringly queries and some scanning boilerplate, not migrations, connection pooling, or transaction policy.
How do zero-downtime deploys work?
Use expand-contract migrations: add nullable columns, dual-write, backfill, then enforce NOT NULL in a later migration.
Can sqlc use pgx?
sqlc supports pgx driver options in generated code - configure in sqlc.yaml per project needs.
Where do embed migrations fit?
See databases section for embed patterns; migrate CLI still needs a filesystem or io.Source at runtime.
Should I validate protobuf separately?
Yes - protobuf checks wire format, not business rules; reuse validator or hand checks on converted DTOs.
How do I test validation?
Table-driven tests on validate.Struct with good and bad DTO literals; no database required.
What about internationalized error messages?
Register custom translations on validator or map tags to locale keys in your HTTP error mapper.
Are validator tags a security boundary?
They are input hygiene, not authorization - always enforce authz in services after validation.
Related
- Migrations with golang-migrate & goose - migration tooling deep dive
- Input Validation & Safe HTML/JSON Handling - security-minded validation
- GORM vs sqlx vs Raw database/sql - data access choices
- Databases Basics - connection and query starters
- Gin: Fast JSON APIs & Binding - framework validation wiring
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).