4.5 KiB
Contract: Database Patterns (Go / MySQL / MariaDB)
Version: 1.5
MySQL Transaction Cursor Safety (CRITICAL)
Never execute SQL on the same transaction while iterating over a query result cursor.
This is the most common source of invalid connection and unexpected EOF driver panics.
Rule
Use a two-phase approach: read all rows first, close the cursor, then execute writes.
// WRONG — executes SQL inside rows.Next() loop on the same tx
rows, _ := tx.Query("SELECT id FROM machines")
for rows.Next() {
var id string
rows.Scan(&id)
tx.Exec("UPDATE machines SET processed=1 WHERE id=?", id) // DEADLOCK / driver panic
}
// CORRECT — collect IDs first, then write
rows, _ := tx.Query("SELECT id FROM machines")
var ids []string
for rows.Next() {
var id string
rows.Scan(&id)
ids = append(ids, id)
}
rows.Close() // explicit close before any write
for _, id := range ids {
tx.Exec("UPDATE machines SET processed=1 WHERE id=?", id)
}
This applies to:
database/sqlwith manual transactions- GORM
db.Raw().Scan()inside adb.Transaction()callback - Any loop that calls a repository method while a cursor is open
Soft Delete / Archive Pattern
Do not use hard deletes for user-visible records. Use an archive flag.
// Schema: is_active bool DEFAULT true
// "Delete" = set is_active = false
// Restore = set is_active = true
// All list queries must filter:
WHERE is_active = true
- Never physically delete rows that have foreign key references or history.
- Hard delete is only acceptable for orphaned/temporary data with no audit trail requirement.
- Archive operations must be reversible from the UI.
GORM Virtual Fields
Use the correct tag based on whether the field should exist in the DB schema:
// Field computed at runtime, column must NOT exist in DB (excludes from migrations AND queries)
Count int `gorm:"-"`
// Field computed at query time via JOIN/SELECT, column must NOT be in migrations
// but IS populated from query results
DisplayName string `gorm:"-:migration"`
gorm:"-"— fully ignored: no migration, no read, no write.gorm:"-:migration"— skip migration only; GORM will still read/write if the column exists.- Do not use
gorm:"-"for JOIN-populated fields — the value will always be zero.
Fail-Fast DB Check on Startup
Always verify the database connection before starting the HTTP server.
sqlDB, err := db.DB()
if err != nil || sqlDB.Ping() != nil {
log.Fatal("database unavailable, refusing to start")
}
// then: run migrations, then: start gin/http server
Never start serving traffic with an unverified DB connection. Fail loudly at boot.
N+1 Query Prevention
Use JOINs or batch IN queries. Never query inside a loop over rows from another query.
// WRONG
for _, pricelist := range pricelists {
items, _ := repo.GetItems(pricelist.ID) // N queries
}
// CORRECT
items, _ := repo.GetItemsByPricelistIDs(ids) // 1 query with WHERE id IN (...)
// then group in Go
Backup Before Any DB Change
Any operation that changes persisted database state must have a fresh backup taken immediately before execution.
This applies to:
- Go migrations
- Manual SQL runbooks
- Data backfills and repair scripts
- Imports, bulk updates, and bulk deletes
- Admin tools or one-off operator commands
Backup naming, storage, archive format, retention, and restore-readiness must follow the backup-management contract.
Rules:
- No schema change or data mutation is allowed on a non-ephemeral database without a current backup.
- "Small" or "safe" changes are not exceptions.
- The operator must know how to restore from that backup before applying the change.
- If a migration or script is intended for production/staging, the rollout instructions must state the backup step explicitly.
- The backup taken before a migration must be triggered by the application's own backup mechanism, not by assuming
mysql,mysqldump, or other DB client tools exist on the user's machine.
Migration Policy
- Migrations are numbered sequentially and never modified after merge.
- Trigger, take, and verify a fresh backup through the application-owned backup mechanism before applying migrations to any non-ephemeral database.
- Each migration must be reversible where possible (document rollback in a comment).
- Never rename a column in one migration step — add new, backfill, drop old across separate deploys.
- Auto-apply migrations on startup is acceptable for internal tools; document if used.