Day 20 — database/sql, Migrations & REST Design
Day 20 — database/sql, Migrations & REST Design
Day 51 — database/sql
Stage VI · ~3h
Goal: Use Go’s portable database/sql correctly—connection pooling, context-aware queries, scanning/sql.Null*, and a small repository with tests (SQLite in-memory is fine; Postgres welcome).
Why this day exists
Stage V services forgot data when the process died. Real systems persist. database/sql is the stdlib façade over drivers:
- You write SQL (or generate it later with sqlc)
- The pool manages connections
- Context cancels in-flight queries
ORMs are optional later; everyone needs to understand *sql.DB.
Theory 1 — Driver + Open
import (
"database/sql"
_ "github.com/jackc/pgx/v5/stdlib" // Postgres example
// OR
_ "modernc.org/sqlite" // pure Go SQLite
)
db, err := sql.Open("sqlite", "file:app.db?_pragma=foreign_keys(1)")
// Open does not verify connectivity — Ping does:
if err := db.PingContext(ctx); err != nil {
return err
}Pool settings
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)Defaults are not always production-safe; set them deliberately.
| Setting | Meaning |
|---|---|
| MaxOpenConns | Cap concurrent DB connections |
| MaxIdleConns | Keep warm conns |
| ConnMaxLifetime | Recycle to avoid load balancer timeouts |
| ConnMaxIdleTime | Drop idle sooner |
Theory 2 — Query APIs
// single row
err := db.QueryRowContext(ctx,
`SELECT id, name FROM items WHERE id = ?`, id,
).Scan(&item.ID, &item.Name)
// many rows
rows, err := db.QueryContext(ctx, `SELECT id, name FROM items`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var it Item
if err := rows.Scan(&it.ID, &it.Name); err != nil {
return err
}
out = append(out, it)
}
return rows.Err()Exec
res, err := db.ExecContext(ctx,
`INSERT INTO items(id, name) VALUES(?, ?)`, id, name)
n, _ := res.RowsAffected()Always prefer *Context variants.
Placeholders
| Driver family | Placeholder |
|---|---|
| SQLite / MySQL | ? |
| Postgres | $1, $2, … |
pgx stdlib uses $n. Do not copy SQLite SQL blindly onto Postgres.
Theory 3 — Nullability
Go zero values cannot express SQL NULL for strings/bools cleanly.
var description sql.NullString
err := row.Scan(&description)
if description.Valid {
s := description.String
_ = s
}Types: NullString, NullInt64, NullFloat64, NullBool, NullTime (and sql.Null[T] in newer Go).
Or scan into *string with driver support—be consistent.
Theory 4 — Transactions
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return err
}
defer tx.Rollback() // no-op after Commit
if _, err := tx.ExecContext(ctx, `...`); err != nil {
return err
}
return tx.Commit()Use transactions for multi-statement atomicity (create order + line items).
Theory 5 — Repository pattern
type ItemRepo struct {
db *sql.DB
}
func (r *ItemRepo) Get(ctx context.Context, id string) (Item, error) {
var it Item
err := r.db.QueryRowContext(ctx,
`SELECT id, name, created_at FROM items WHERE id = ?`, id,
).Scan(&it.ID, &it.Name, &it.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return it, ErrNotFound
}
return it, err
}Map sql.ErrNoRows to domain ErrNotFound. Keep SQL in one layer.
Worked example — SQLite schema + CRUD
const schema = `
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL
);
`
func Migrate(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, schema)
return err
}
func (r *ItemRepo) Create(ctx context.Context, id, name string) error {
_, err := r.db.ExecContext(ctx,
`INSERT INTO items(id, name, created_at) VALUES(?, ?, ?)`,
id, name, time.Now().UTC().Format(time.RFC3339Nano),
)
return err
}For tests:
db, err := sql.Open("sqlite", "file:test.db?mode=memory&cache=shared")
// or modernc DSN per driver docsLab 1 — Module setup
mkdir -p ~/lab/90daysofx/01-go/day51
cd ~/lab/90daysofx/01-go/day51
go mod init example.com/day51
# pick one:
go get modernc.org/sqlite
# or: go get github.com/jackc/pgx/v5/stdlibDocument driver choice in README.
Lab 2 — Repository CRUD
Implement:
Create
Get
List
Delete
Migrate
Table tests against in-memory SQLite (or testcontainers later on Day 61).
func TestItemRepo(t *testing.T) {
db := openTestDB(t)
repo := &ItemRepo{db: db}
ctx := context.Background()
if err := repo.Create(ctx, "1", "widget"); err != nil {
t.Fatal(err)
}
it, err := repo.Get(ctx, "1")
if err != nil || it.Name != "widget" {
t.Fatalf("%+v %v", it, err)
}
}Lab 3 — ErrNoRows mapping
Get missing id → errors.Is(err, ErrNotFound). Never leak raw sql.ErrNoRows to HTTP layer without mapping (HTTP mapping is Day 53+; define domain error today).
Lab 4 — Pool / Ping
On startup path: PingContext with timeout. Unit test optional; manual check fine.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
log.Error("db", "err", err)
os.Exit(1)
}Lab 5 — Transaction transfer (stretch)
Two accounts table; Transfer(ctx, from, to, amount) in a transaction; test rollback on insufficient funds.
Common gotchas
| Gotcha | Fix |
|---|---|
Forgetting rows.Close |
defer rows.Close() |
Ignoring rows.Err |
Check after loop |
Open without Ping |
Ping on startup |
| Holding transactions open during HTTP calls out | Keep tx short |
| Wrong placeholders for driver | Match driver |
| Scanning NULL into string | sql.NullString |
| Unbounded MaxOpenConns | Set pool caps |
Using DB after Close |
Lifecycle ownership clear |
Checkpoint
- Migrate + CRUD repo works
- All queries use Context
ErrNotFoundmapping tested
- Pool settings set intentionally
rows.Close+rows.Errcorrect
- Driver choice documented
Commit
git add .
git commit -m "day51: database/sql repository pooling and CRUD"Write three personal gotchas before continuing.
Tomorrow
Day 52 — migrations & tooling: versioned schema changes, and a deliberate sqlc vs ORM trade-off—not cargo-cult.
Day 52 — migrations & SQL tooling
Stage VI · ~3h
Goal: Manage schema with versioned migrations, understand sqlc vs ORM trade-offs, and apply migrations cleanly in app startup and tests.
Why this day exists
CREATE TABLE IF NOT EXISTS is fine for Day 51 toys. Production schemas evolve:
- Add columns, indexes, constraints
- Backfill data
- Coordinate multi-instance deploys
Without migrations you get “works on my machine” drift. Without a tooling philosophy you either drown in ORM magic or copy-paste SQL forever. Today freezes the mental model you will use through the capstone.
Theory 1 — Migration principles
- Forward versioned files —
0001_init.up.sql,0002_add_email.up.sql
- Expand/contract for zero-downtime when needed (add nullable → backfill → constrain)
- Never edit applied migrations in shared environments—add a new one
- Down migrations optional; many teams prefer forward-fix only
- Run migrations as a separate step or gated startup with locking
Minimal migration table
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
);Record each applied version. The version string is usually the filename prefix (0001_init).
Expand / contract in one paragraph
Ship a migration that adds a nullable column while old code ignores it; deploy new code that writes both old and new; backfill; then a later migration enforces NOT NULL. Dropping columns requires the reverse: stop reading → deploy → drop. This is how two binary versions coexist during a rolling deploy.
Theory 2 — Lightweight DIY migrator (lab-friendly)
type Migration struct {
Version string
SQL string
}
func Apply(ctx context.Context, db *sql.DB, migrations []Migration) error {
if err := ensureTable(ctx, db); err != nil {
return err
}
for _, m := range migrations {
ok, err := applied(ctx, db, m.Version)
if err != nil {
return err
}
if ok {
continue
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, m.SQL); err != nil {
_ = tx.Rollback()
return fmt.Errorf("migration %s: %w", m.Version, err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`,
m.Version, time.Now().UTC().Format(time.RFC3339),
); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}
func ensureTable(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
)`)
return err
}
func applied(ctx context.Context, db *sql.DB, version string) (bool, error) {
var n int
err := db.QueryRowContext(ctx,
`SELECT COUNT(1) FROM schema_migrations WHERE version = ?`, version,
).Scan(&n)
return n > 0, err
}Embed SQL with //go:embed migrations/*.sql for shippable binaries.
//go:embed migrations/*.sql
var migrationFS embed.FSProduction often uses golang-migrate, goose, atlas, or pressly/goose—pick one for real services; DIY teaches the model.
Theory 3 — sqlc vs ORM
| Approach | What you write | Pros | Cons |
|---|---|---|---|
Raw database/sql |
SQL strings | Full control, no codegen | Boilerplate Scan |
| sqlc | SQL → generated Go | Type-safe, explicit SQL, fast | Codegen step; SQL skill required |
| ORM (GORM, ent, …) | Struct tags / DSLs | Speed of CRUD | Hidden queries, magic, migrations coupling |
Expert recommendation for this volume
- Prefer explicit SQL (raw or sqlc-style).
- Use ORM only if you already know its failure modes.
- Capstone default:
database/sql+ migrations + thin repo; sqlc welcome.
sqlc mental model
-- name: GetItem :one
SELECT id, name, description FROM items WHERE id = ?;Generates GetItem(ctx, id) (Item, error) with correct types. You review SQL in PR diffs—good for teams.
ORM footguns (awareness)
- N+1 queries via lazy loading
- Auto-migrate in production by accident
- Unbounded
Findwithout limits
- Struct tag soup
- “Repository” that is really the ORM session leaking
Theory 4 — Migration + app lifecycle
deploy:
1. migrate job (or init container)
2. start N app replicas
local:
go run ./cmd/migrate
go run ./cmd/service
Avoid racing multiple app instances each trying complex non-transactional migrations without locks. SQLite DIY in a single process is fine for labs; multi-writer Postgres needs advisory locks or a dedicated migrate step.
// optional startup gate for single-instance tools
if os.Getenv("RUN_MIGRATIONS") == "1" {
if err := migrate.Apply(ctx, db, all); err != nil {
return err
}
}Theory 5 — Data migrations vs schema
- Schema: add column, index, constraint
- Data:
UPDATE ... SETbackfill
Sometimes split for safety (schema first, data in a controlled job). Always backup mindset on prod (even if lab skips). Never SELECT * assumptions after schema changes—name columns in app SQL.
Theory 6 — Ordering and naming
migrations/
0001_init.sql
0002_add_description.sql
0003_items_name_unique.sql
- Zero-pad so lexicographic order == apply order
- One logical change per file when possible
- Include index creation in the migration that needs it for correctness
Worked example — two-step migration set
migrations/0001_init.sql:
CREATE TABLE items (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL
);migrations/0002_add_description.sql:
ALTER TABLE items ADD COLUMN description TEXT NOT NULL DEFAULT '';Repo update: Scan description. Tests apply all migrations from empty DB.
func Load(fs embed.FS) ([]Migration, error) {
entries, err := fs.ReadDir("migrations")
if err != nil {
return nil, err
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
var out []Migration
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
b, err := fs.ReadFile("migrations/" + e.Name())
if err != nil {
return nil, err
}
ver := strings.TrimSuffix(e.Name(), ".sql")
out = append(out, Migration{Version: ver, SQL: string(b)})
}
return out, nil
}Lab 1 — Setup
mkdir -p ~/lab/90daysofx/01-go/day52/migrations
cd ~/lab/90daysofx/01-go/day52
go mod init example.com/day52Carry repo from Day 51 or reimplement a minimal items table + database/sql access.
Lab 2 — Implement Apply
- Embed or read migration files sorted by name.
- Apply to empty DB.
- Re-run Apply → no-op (idempotent).
- Test that
schema_migrationshas expected versions.
go test ./migrate/ -count=1 -raceLab 3 — Evolve schema
Add description migration; update Create/Get; tests pass on fresh DB.
Simulate “old code” vs “new code” discussion in README: why expand/contract matters when two versions run together. One short paragraph is enough.
Lab 4 — Tool choice writeup
In NOTES.md or README section (≤1 page):
- Would you use sqlc, raw SQL, or ORM for a 5-table service? Why?
- One risk of auto-migrate.
- One benefit of SQL in PRs.
No wrong answer if reasoned.
Lab 5 — Optional sqlc hello
If you want codegen experience:
# install sqlc; write sqlc.yaml; sqlc generateGenerate GetItem only. Not required for gate. If you skip, write one sentence why raw SQL is enough for this lab week.
Common gotchas
| Gotcha | Fix |
|---|---|
| Editing old migrations | Add new version |
| Non-deterministic file order | Zero-pad numeric prefixes |
| DDL outside transaction (some DBs) | Know engine limits |
| Auto-migrate prod casually | Review + backup |
| App assumes schema without migrating | Startup check / migrate job |
| Ignoring partial failure | Transaction per migration when possible |
| SQLite vs Postgres placeholders | ? vs $1; abstract or dual files |
| Forgetting indexes until prod hurts | Add with the query that needs them |
Checkpoint
- Versioned migrations apply idempotently
- Second migration evolves schema + code
- Tests start from empty DB + migrations
- Written sqlc vs ORM trade-off note
- Can explain expand/contract in one paragraph
go test -racegreen on migrate package
Commit
git add .
git commit -m "day52: versioned migrations and sql tooling notes"Write three personal gotchas before continuing.
Tomorrow
Day 53 — REST design: resource modeling, status codes, versioning, and a stdlib mux JSON API over your repository.
Day 53 — REST design
Stage VI · ~3h
Goal: Design a versioned JSON REST API on stdlib ServeMux, backed by your repository—clear resources, status codes, pagination basics, and consistent response envelopes.
Why this day exists
CRUD handlers without REST thinking become ad-hoc RPC-over-HTTP:
- Verbs in paths (
/getItem)
- Always HTTP 200 with error in body
- No resource identifiers
- Breaking changes without versioning
Today you align HTTP semantics with domain operations so Day 54 auth and Day 55 errors attach cleanly.
Theory 1 — Resources, not actions
| Prefer | Avoid |
|---|---|
GET /v1/items |
GET /v1/getItems |
GET /v1/items/{id} |
GET /v1/item?id= only |
POST /v1/items |
POST /v1/createItem |
DELETE /v1/items/{id} |
POST /v1/items/delete |
Collections vs items:
- Collection:
/v1/items
- Member:
/v1/items/{id}
- Subresource:
/v1/items/{id}/tags
Nouns scale; verb paths invent a private RPC dialect per team.
Theory 2 — Status code cheat sheet
| Code | When |
|---|---|
| 200 | OK with body |
| 201 | Created (often + Location header) |
| 204 | OK no body (delete/update) |
| 400 | Malformed / validation failed |
| 401 | Unauthenticated |
| 403 | Authenticated but not allowed |
| 404 | Missing resource |
| 409 | Conflict (duplicate id) |
| 415 | Unsupported Content-Type |
| 422 | Semantic validation (optional; some teams use 400) |
| 429 | Rate limit |
| 500 | Unexpected server bug |
| 503 | Dependency down |
Do not invent “200 OK + {"success":false}” as the primary pattern. Clients, load balancers, and caches all understand status codes better than inventing a second protocol inside JSON.
Theory 3 — Versioning strategies
| Strategy | Example | Notes |
|---|---|---|
| URL prefix | /v1/items |
Simple, explicit—use this today |
| Header | Accept: application/vnd.myapp.v1+json |
Flexible, harder to explore |
| No version | /items |
OK until first break—still learn v1 habit |
Breaking change → /v2. Additive fields are usually non-breaking if clients ignore unknown JSON keys. Removing or renaming fields is breaking.
Theory 4 — Response shapes
Success
{
"id": "01H...",
"name": "widget",
"created_at": "2026-07-27T12:00:00Z"
}List + pagination (basic)
{
"items": [ ... ],
"next_cursor": "abc",
"limit": 20
}Cursor pagination beats large OFFSET for big tables—mention even if you implement only limit today.
limit := 20
if v := r.URL.Query().Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid limit")
return
}
if n > 100 {
n = 100
}
limit = n
}Errors (preview Day 55)
{
"error": {
"code": "not_found",
"message": "item not found"
}
}Stay consistent across handlers—copy one helper, not six shapes.
Theory 5 — Idempotency & methods
| Method | Idempotent? | Safe? |
|---|---|---|
| GET | yes | yes |
| PUT | yes | no |
| DELETE | yes | no |
| POST | no (usually) | no |
| PATCH | often conditional | no |
PUT can mean “replace resource at id” (client-supplied id). POST “create under collection” (server id). Pick and document.
Idempotency keys for POST (stretch): client sends Idempotency-Key; server stores request hash → response for replay. Awareness is enough today.
Theory 6 — DTO vs domain vs DB
HTTP JSON (DTO) ↔ domain/service ↔ SQL row
Do not leak DB column names or internal flags to clients without intent. Map explicitly:
type ItemDTO struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
}
func toDTO(it store.Item) ItemDTO {
return ItemDTO{
ID: it.ID, Name: it.Name,
CreatedAt: it.CreatedAt.UTC().Format(time.RFC3339),
}
}Worked example — wiring repo to HTTP
func (s *Server) routes() {
s.mux.HandleFunc("GET /v1/items", s.listItems)
s.mux.HandleFunc("POST /v1/items", s.createItem)
s.mux.HandleFunc("GET /v1/items/{id}", s.getItem)
s.mux.HandleFunc("DELETE /v1/items/{id}", s.deleteItem)
s.mux.HandleFunc("GET /healthz", s.health)
}
func (s *Server) getItem(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
it, err := s.repo.Get(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "not_found", "item not found")
return
}
s.log.Error("get item", "err", err, "id", id)
writeErr(w, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, http.StatusOK, toDTO(it))
}
func (s *Server) createItem(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := readJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if strings.TrimSpace(req.Name) == "" {
writeErr(w, http.StatusBadRequest, "validation", "name is required")
return
}
id := newID()
if err := s.repo.Create(r.Context(), id, req.Name); err != nil {
s.log.Error("create item", "err", err)
writeErr(w, http.StatusInternalServerError, "internal", "internal error")
return
}
w.Header().Set("Location", "/v1/items/"+id)
writeJSON(w, http.StatusCreated, ItemDTO{ID: id, Name: req.Name})
}Helpers (readJSON, writeJSON, writeErr) should live once in the api package.
Lab 1 — Compose service
mkdir -p ~/lab/90daysofx/01-go/day53
cd ~/lab/90daysofx/01-go/day53
go mod init example.com/day53Integrate: migrations (Day 52) + repo (Day 51) + HTTP (Day 45 style) + slog. Prefer real SQLite file or :memory: with migrations applied at startup for local demo.
Lab 2 — REST compliance checklist
For each endpoint, document in README:
- Method + path
- Request body schema
- Success status + body
- Error statuses
Implement list with ?limit= (default 20, max 100).
Lab 3 — Tests as contract
httptest tables:
- Create → 201 + Location
- Get → 200
- Get missing → 404 + error code
- Invalid JSON → 400
- List limit enforcement (101 → clamped or 400—document which)
go test ./api/ -race -count=1Lab 4 — Conflict path
Unique name constraint (optional unique index migration). Second create → 409 with code=conflict. Map store.ErrConflict carefully; never return raw SQL UNIQUE text to clients.
Lab 5 — Curl script
scripts/smoke.sh exercising happy path. Runnable after go run:
#!/usr/bin/env bash
set -euo pipefail
base=${1:-http://127.0.0.1:8080}
curl -sf "$base/healthz" >/dev/null
id=$(curl -sf -X POST "$base/v1/items" -H 'Content-Type: application/json' \
-d '{"name":"smoke"}' | jq -r .id)
curl -sf "$base/v1/items/$id" | jq .
echo OKCommon gotchas
| Gotcha | Fix |
|---|---|
| RPC path names | Resource nouns |
| 200 for errors | Proper status codes |
| Leaking SQL errors to clients | Map to stable codes |
| Unbounded list | limit/cursor |
| Mixing v0 unversioned breakages | /v1 prefix now |
| Wrong method semantics | Follow table |
Forgetting Location on 201 |
Set header |
| Scanning into DTO from SQL directly | Map at boundary |
Checkpoint
- Versioned routes under
/v1
- Status codes match cheat sheet
- Repo errors mapped to HTTP
- Pagination limit enforced
- Contract tests green
- README endpoint table
Commit
git add .
git commit -m "day53: versioned REST JSON API over repository"Write three personal gotchas before continuing.
Tomorrow
Day 54 — auth basics: protect routes with sessions or JWT—authentication vs authorization, middleware, and safe defaults.