Updated Jun 23, 2026

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.

err := db.Transaction(func(tx *gorm.DB) error {
    user := User{Name: "Ada", Email: "[email protected]"}
    if err := tx.Create(&user).Error; err != nil {
        return err // rolls back — nothing is saved
    }

    post := Post{Title: "Hello, world", UserID: user.ID}
    if err := tx.Create(&post).Error; err != nil {
        return err // rolls back — the user insert is undone too
    }

    return nil // commits both
})
if err != nil {
    log.Println("signup failed, nothing was written:", err)
}

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 outer db. This is the single most common transaction bug in GORM. tx is the transactional handle; db is the plain connection. If you accidentally write db.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 through tx.

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.

tx := db.Begin()
defer func() {
    if r := recover(); r != nil {
        tx.Rollback() // a panic shouldn't leave the transaction open
    }
}()

if err := tx.Create(&user).Error; err != nil {
    tx.Rollback()
    return err
}

if err := tx.Create(&post).Error; err != nil {
    tx.Rollback()
    return err
}

tx.Commit()

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 tx too. The whole point is that db.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.

import "github.com/google/uuid"

func (u *User) BeforeCreate(tx *gorm.DB) error {
    u.UUID = uuid.NewString()
    return nil
}

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
ALTER TABLE posts ADD COLUMN published BOOLEAN NOT NULL DEFAULT false;
-- 000004_add_published_to_posts.down.sql
ALTER TABLE 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:

migrate -path ./migrations -database "$DATABASE_URL" 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: AutoMigrate to 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): return nil to commit, return an error or panic to roll back automatically.
  • ⚠️ Inside the closure, use tx, not the outer db — the most common transaction bug. Use the manual form (Begin/Commit/Rollback with a deferred recover) only when the control flow is too complex for a closure.
  • Hooks run your code around DB opsBeforeCreate, 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.
  • AutoMigrate is 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 with migrate up, tracked in a schema_migrations table.

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?

Was this page helpful?