Transactions, Hooks & Migrations
By Phase 7 you can read your blog efficiently. Now we deal with the three things that separate a toy from something you'd run in production: writes that can't half-finish, code that runs automatically around DB operations, and a schema you can evolve safely over time.
The mental model: three guardrails
These three features feel unrelated until you see what they share — each one is a guardrail around the moment data changes.
- A transaction makes several writes all-or-nothing. Either every write lands, or none of them do. No half-finished state.
- A hook runs your code around a DB op — right before an insert, right after a find. It's a place to hang behavior that should always happen.
- A migration versions your schema over time — an ordered, reviewable history of how your tables got to their current shape.
💡 Hold "transaction = all-or-nothing write, hook = code that fires around the op, migration = versioned schema history" and the rest of this phase is just syntax. The hard part is knowing when to reach for each, which is what we'll spend most of our time on.
If you've never met the all-or-nothing idea formally, the background — atomicity, consistency, the rest of ACID — lives in Transactions & ACID. Here we focus on how GORM hands it to you.
Transactions: writes that succeed together or not at all
Picture signing up a new blog user and creating their welcome post in one go. If the user insert succeeds but the post insert fails, you've got a user with no welcome post and a confusing half-state. A transaction collapses those two writes into one unit: both land, or neither does.
The closure form (reach for this first)
GORM's Transaction method takes a function. If your function returns nil, GORM commits. If it
returns an error — or panics — GORM rolls back automatically. You never call commit or rollback
yourself.
db
if err != nil
What just happened: GORM opened a transaction, handed your function a special *gorm.DB called tx,
and watched your return value. The first Create inserts the user; the second uses user.ID (GORM
filled it in after the first insert) to link the post to that user. If either Create returns an
error, you return it, and GORM rolls back — so a failed post insert also undoes the user insert. The
database never sees a user without their welcome post.
⚠️ Inside the closure, use
tx, not the outerdb. This is the single most common transaction bug in GORM.txis the transactional handle;dbis the plain connection. If you accidentally writedb.Create(&post)inside the closure, that write runs outside the transaction — it won't roll back with the rest, and you're back to half-finished state. Every DB call in the block should go throughtx.
The manual form (when the closure doesn't fit)
Sometimes the control flow is too tangled for a single closure — you're branching across helper
functions, or commit timing depends on logic the closure can't cleanly express. Then you drive the
transaction by hand with Begin, Commit, and Rollback.
db
defer
if tx.Error; err != nil
if tx.Error; err != nil
tx
What just happened: db.Begin() starts the transaction and gives you tx. Now you own the
outcome: every error path calls tx.Rollback(), the happy path ends with tx.Commit(), and the
deferred recover makes sure that even a panic rolls back instead of leaving a half-open transaction
holding locks. It's more code and more ways to slip up — which is exactly why the closure form is the
default. Reach for manual control only when you genuinely need it.
💡 Same rule, restated: in the manual form, every write goes through
txtoo. The whole point is thatdb.Begin()returns a new handle scoped to this transaction.
Hooks: your code, run automatically around DB ops
A hook is a method you define on your model that GORM calls at a specific moment in a record's life. You don't invoke it — GORM does, every time the matching operation runs. The full lifecycle:
| Hook | Fires |
|---|---|
BeforeCreate(tx *gorm.DB) error |
just before an INSERT |
AfterCreate(tx *gorm.DB) error |
just after an INSERT |
BeforeSave / BeforeUpdate |
before a save / update |
BeforeDelete |
before a delete |
AfterFind(tx *gorm.DB) error |
after a row is loaded from the DB |
The classic use is filling in a field that should always be set on insert — a UUID, or a hashed password — so no caller can forget to do it.
What just happened: you added a BeforeCreate method to User. Now every time anything inserts a
user — db.Create(&user), or a tx.Create inside a transaction — GORM calls this method first and
stamps a fresh UUID onto the record before it hits the database. The caller writes nothing extra; the
guarantee lives on the model. A password hook works the same way: hash u.Password in BeforeCreate
(or BeforeSave) and a plaintext password can never be written by accident.
The other half of the contract: returning an error from a hook aborts the operation. Return a
non-nil error from BeforeCreate and the insert is cancelled — and if you're inside a transaction,
that error rolls the whole transaction back. So a validation hook is a clean way to reject bad data
before it lands.
⚠️ Hooks are hidden behavior. Someone reading
db.Create(&user)has no visual hint that a UUID is being minted, a password is being hashed, or a network call is firing. Keep hooks small, fast, and predictable — set a field, validate a value, return. Don't put slow I/O (sending email, calling an external API) in a hook: it runs inside the surrounding transaction, so it holds the transaction open and a failure rolls back your write for reasons that are hard to trace. When in doubt, do the heavy work in plain application code where it's visible.
Migrations: versioning your schema, not just creating it
Back in Phase 2 you met AutoMigrate, and the warning that came with
it. Here's why that warning matters once real data is involved.
AutoMigrate is additive only. It creates tables and adds missing columns, indexes, and foreign
keys. It will never drop a column, never delete a table, and never change a column's type in a way that
could lose data. That's perfect in development — you can't accidentally nuke anything — but it means
AutoMigrate cannot express a rename, a drop, or a careful type change. Rename a struct field
and AutoMigrate adds a new column beside the old one, leaving the original data stranded. There's also
no record of what changed when and no way to undo a step.
Production wants the opposite: a schema history that's reproducible (run the same steps, get the same schema), reversible (every change has an undo), and reviewable (changes are files in your repo that go through code review). That's what versioned migrations give you, via a dedicated tool like golang-migrate, goose, or atlas.
The shape is the same across all of them: ordered pairs of SQL files, an up (apply the change) and a down (undo it).
-- 000004_add_published_to_posts.up.sql
posts ADD COLUMN published BOOLEAN NOT NULL DEFAULT false;
-- 000004_add_published_to_posts.down.sql
posts DROP COLUMN published;
What just happened: you wrote the change and its reverse, as plain, reviewable SQL, numbered so it
runs in a fixed order. The up file adds a published flag to the blog's posts table; the down
file removes it again if you need to roll the change back. Because it's hand-written SQL, you can do
the things AutoMigrate can't — drops, renames, data backfills, careful type changes — and a teammate
can read the diff before it ships.
You apply pending migrations by running the tool — for golang-migrate, that's migrate up:
What just happened: the tool looked at your database, checked a bookkeeping table it maintains
(commonly schema_migrations) to see which numbered migrations have already run, and applied only the
new ones in order. Run it again with nothing new and it does nothing. That tracking table is the whole
trick — it's how the tool knows your schema's exact version and can move it forward (or, with down,
backward) one reviewable step at a time. The full discipline — naming, ordering, backfills, zero-downtime
changes — is its own topic in Database Migrations.
📝 The honest division of labor:
AutoMigrateto get moving fast in dev and on side projects; versioned migrations the moment real users have real data in the table. Many teams use both — AutoMigrate locally for speed, a migration tool in CI and production for safety. Use the right one for the stakes.
Recap
- A transaction makes several writes all-or-nothing. Prefer the closure form (
db.Transaction): returnnilto commit, return an error or panic to roll back automatically. - ⚠️ Inside the closure, use
tx, not the outerdb— the most common transaction bug. Use the manual form (Begin/Commit/Rollbackwith a deferredrecover) only when the control flow is too complex for a closure. - Hooks run your code around DB ops —
BeforeCreate,AfterCreate,BeforeSave,AfterFind, and friends. Great for stamping a UUID or hashing a password so no caller can forget. Returning an error aborts the operation (and rolls back the surrounding transaction). - ⚠️ Keep hooks small and predictable — they're hidden behavior; no slow I/O inside them.
AutoMigrateis additive only (no drops, renames, or destructive type changes) — fine for dev, not a production migration strategy. Use versioned migrations (golang-migrate, goose, atlas): ordered up/down SQL, applied withmigrate up, tracked in aschema_migrationstable.
Quick check
[
{
"q": "Inside a db.Transaction(func(tx *gorm.DB) error { ... }) closure, which handle should your writes use?",
"choices": ["The outer db, so they share a connection", "tx, the transactional handle passed in", "Either one — GORM tracks both", "A new db.Begin() call inside the closure"],
"answer": 1,
"explain": "Use tx. The outer db runs outside the transaction, so a write through db won't roll back with the rest — the classic GORM transaction bug."
},
{
"q": "What happens when a BeforeCreate hook returns a non-nil error?",
"choices": ["GORM logs it but inserts the row anyway", "The insert is aborted, and inside a transaction it rolls back", "Only that field is skipped", "The hook is retried until it returns nil"],
"answer": 1,
"explain": "Returning an error from a hook aborts the operation, and if it's running inside a transaction the whole transaction rolls back. That makes hooks a clean place to reject bad data."
},
{
"q": "Why isn't AutoMigrate enough for evolving a production schema?",
"choices": ["It only works with SQLite", "It is additive only — it can't drop, rename, or destructively retype, and keeps no reversible history", "It is too slow on large tables", "It requires the database to be empty"],
"answer": 1,
"explain": "AutoMigrate only adds tables/columns/indexes; it can't express renames, drops, or careful type changes, and there's no ordered, reversible, reviewable history. Versioned migrations (up/down SQL) cover that."
}
]
← Phase 7: Preloading & the N+1 Trap · Guide overview · Phase 9: GORM in the Real World & Where to Go Next →
Check your understanding
1. Inside a db.Transaction(func(tx *gorm.DB) error { ... }) closure, which handle should your writes use?
2. What happens when a BeforeCreate hook returns a non-nil error?
3. Why isn't AutoMigrate enough for evolving a production schema?