# GORM From Zero

> Learn Go's most popular ORM: connecting and the model, auto-migration, create and read, querying, update and delete with soft deletes, associations, preloading and the N+1 trap, transactions and hooks and real migrations. The data layer most Go web services use, made plain - including where to drop back to SQL.


---

# GORM From Zero

Most Go web services that talk to a SQL database talk to it through GORM. It's the de-facto ORM for Go:
you define your tables as plain structs, and GORM handles the SQL for create, read, update, delete,
relationships, and migrations. If you've used an ORM in another language, GORM will feel familiar; if you
haven't, it's the gentlest way into "describe your data as Go types, let the library write the SQL." And
because Go developers value knowing what's really happening, the most useful thing about learning GORM is
seeing exactly which SQL each call produces - so you can drop to raw SQL the moment the ORM gets in your way.

The mental model is three pieces. A **model** is a Go struct that maps to a table (often embedding
`gorm.Model` for `ID`/timestamps/soft-delete). The **`*gorm.DB`** value is your handle to the database
and the thing you chain methods on (`db.Where(...).Order(...).Find(...)`). And a **session/chain** is how
those calls build one query. Hold "struct = table, `*gorm.DB` = the query builder you chain," and GORM
stops being magic and becomes a SQL generator you can reason about.

> 📝 This teaches the **library** - it assumes you know **Go** (structs, methods, pointers, slices - 
> [Go From Zero](/guides/go-from-zero)) and basic **databases** (tables, keys, joins - 
> [What a Database Is](/guides/what-a-database-is)). The ORM concepts transfer directly from
> [Hibernate & JPA](/guides/hibernate-and-jpa-from-zero) and [SQLAlchemy](/guides/sqlalchemy-from-zero).
> GORM needs a real database (the examples use SQLite for zero setup) and runs as a Go program, so
> examples are shown with their output.

## How to read this

Read in order - it builds one schema (a small **blog**: users, posts, comments) from a bare connection up
to associations, the N+1 trap, and migrations. Phases carry difficulty badges.

## The phases

**Part 1 - Foundations (🟢 → 🟡)**
1. **[What GORM Is & Connecting](01-what-gorm-is.md)** 🟢 - the ORM idea, opening a `*gorm.DB`, and seeing the SQL it logs.
2. **[Models & Auto-Migration](02-models-and-migration.md)** 🟡 - structs as tables, tags, `gorm.Model`, and `AutoMigrate`.
3. **[Create & Read](03-create-and-read.md)** 🟡 - `Create`, `First`/`Find`/`Take`, and how records round-trip.

**Part 2 - Real queries (🟡 → 🔴)**
4. **[Querying](04-querying.md)** 🟡 - `Where`, `Order`, `Limit`/`Offset`, `Select`, and reusable scopes.
5. **[Update & Delete](05-update-and-delete.md)** 🟡 - `Save`/`Updates`, the zero-value trap, and soft vs hard deletes.
6. **[Associations](06-associations.md)** 🔴 - has-one, has-many, belongs-to, and many-to-many with join tables.
7. **[Preloading & the N+1 Trap](07-preloading-and-n-plus-1.md)** 🔴 - lazy vs `Preload`/`Joins`, and the query explosion that bites everyone.

**Part 3 - Real projects (🔴 → 🟢)**
8. **[Transactions, Hooks & Migrations](08-transactions-hooks-migrations.md)** 🔴 - `Transaction`, lifecycle hooks, and why `AutoMigrate` isn't enough in production.
9. **[GORM in the Real World & Where to Go Next](09-where-to-go-next.md)** 🟢 - when to drop to raw SQL, GORM vs sqlc/sqlx, and what to build.

> The throughline: a **struct is a table**, and **`*gorm.DB` is a query you chain**. Keep an eye on the
> SQL it generates and you stay in command of your database instead of fighting the ORM.


---

# What GORM Is & Connecting

Here's the situation you're walking into. You've got a Go service, and somewhere it needs to read and
write rows in a SQL database. You *could* hand-write every `INSERT`, `SELECT`, and `UPDATE`, scanning
columns into structs by hand, one `rows.Scan(&u.ID, &u.Name, ...)` at a time. People do - it works, but
it's a lot of repetitive, error-prone plumbing, and the moment your schema changes, you're hunting down
every query that touched that column.

GORM is what most Go web services reach for instead. It's the de-facto ORM for Go, and the trade it
offers is straightforward: you describe your tables as plain Go structs, and GORM writes the SQL for
create, read, update, delete, relationships, and even migrations. Less boilerplate, fewer typos in column
names, and your data shape lives in one place - the struct.

> 📝 This phase teaches the **library**. It assumes you know **Go** (structs, pointers, slices - 
> [Go From Zero](/guides/go-from-zero)) and the basics of **databases** (tables, rows, keys - 
> [What a Database Is](/guides/what-a-database-is)). If you've used an ORM in another language, the core
> ideas transfer directly - see [SQLAlchemy From Zero](/guides/sqlalchemy-from-zero) for the same
> concepts in Python.

## The real cost

Every convenience has a bill attached, and it's only fair to name GORM's up front. When a library writes
your SQL for you, you stop *seeing* your SQL - and that's exactly where ORMs earn their bad reputation. A
single innocent-looking method call can fire off a query you'd never have written by hand, and if you're
not watching, you find out in production when something is slow.

⚠️ The cure isn't avoiding GORM - it's **watching the SQL it generates**. Go developers tend to value
knowing what's really happening, and GORM rewards that instinct: it can log every query it runs. Turn
that on while you learn (we'll do it in a minute), and the ORM stops being a black box. You'll see the
`INSERT` behind a `Create`, the `SELECT` behind a `Find`, and you'll know the moment a call does
something expensive.

## The mental model

Before any setup, hold this picture. It's the whole guide in one line:

> **A struct is a table. A `*gorm.DB` is the query you chain. GORM writes the SQL.**

Three pieces:

- **The struct = the table.** You define a `User` struct, and GORM treats it as the `users` table. Fields
  become columns. (Phase 2 covers exactly how.)
- **The `*gorm.DB` = the query you chain.** This is the value you get back from connecting. You build up a
  query by chaining methods on it - `db.Where("age > ?", 18).Order("name").Find(&users)` - and each link
  in the chain adds to the SQL that finally runs.
- **GORM writes the SQL.** You think in structs and method calls; GORM translates that into the real
  `SELECT ... WHERE ... ORDER BY ...` and hands it to the database driver.

💡 And here's the freedom: GORM is a **SQL generator**, not a cage. When the high-level API gets awkward - 
a gnarly report, a bulk update - you drop straight to raw SQL with `db.Raw(...)` or `db.Exec(...)`, in the
same program, against the same connection. You never lose access to the database underneath.

## Installing GORM

GORM is two pieces: the core library, and a **driver** for your specific database. We'll use SQLite - it
needs zero setup (the database is just a file), so you can run everything in this guide without installing
a server.

From inside a Go module (`go mod init blog` if you're starting fresh), pull them in:

```bash
go get gorm.io/gorm
go get gorm.io/driver/sqlite
```

*What just happened:* `go get` downloaded two packages and added them to your `go.mod`. The first is GORM
itself. The second is the SQLite driver - the adapter that teaches GORM how to speak SQLite specifically.
GORM ships separate drivers for each database; if you later move to PostgreSQL or MySQL, you swap the
driver (`gorm.io/driver/postgres` or `gorm.io/driver/mysql`) and almost nothing else changes.

## Opening a connection

Now the connection itself. This is the single most important call in the library, because it hands you the
`*gorm.DB` that everything else hangs off of:

```go
package main

import (
	"log"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

func main() {
	db, err := gorm.Open(sqlite.Open("blog.db"), &gorm.Config{})
	if err != nil {
		log.Fatal(err)
	}

	log.Println("connected:", db)
}
```

Run it with `go run .`.

*What just happened:* `gorm.Open` took two arguments and gave back two values. The first argument is the
**dialector** - `sqlite.Open("blog.db")` says "use the SQLite driver, and point it at a file called
`blog.db`." (That file is created automatically if it doesn't exist.) The second argument, `&gorm.Config{}`,
is GORM's settings bag - empty here, meaning "all defaults." Back out comes `db`, a **`*gorm.DB`** - your
handle to the database, the thing you'll chain every query on - plus an `err`. Checking that error and
bailing with `log.Fatal` is the standard Go move: if the connection failed, there's no point continuing.

📝 `db` is a long-lived value. You open it **once** at startup and pass it around your program - you do
*not* call `gorm.Open` per request. Under the hood it manages a pool of connections for you.

## Turn on the SQL logger

Remember the real cost - not seeing your SQL? Here's the fix, and it's the single best habit you can
build while learning. GORM has a built-in logger; tell it to log at `Info` level and it prints every query
it runs:

```go
package main

import (
	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

func main() {
	db, err := gorm.Open(sqlite.Open("blog.db"), &gorm.Config{
		Logger: logger.Default.LogMode(logger.Info),
	})
	if err != nil {
		panic(err)
	}

	_ = db
}
```

*What just happened:* the only change is the `Logger` field in the config. `logger.Default` is GORM's
out-of-the-box logger; `.LogMode(logger.Info)` cranks it up so it reports *every* SQL statement (at the
quieter default levels it only speaks up for errors or slow queries). The extra import,
`"gorm.io/gorm/logger"`, is what gives you `logger.Default` and `logger.Info`. From now on, every GORM
call in this guide leaves a trail you can read.

Once it's on, a query you'll meet in Phase 3 shows up in your terminal looking roughly like this:

```sql
[1.204ms] [rows:1] SELECT * FROM `users` WHERE `users`.`id` = 1 ORDER BY `users`.`id` LIMIT 1
```

*What just happened:* GORM printed the exact SQL it generated, how long it took (`1.204ms`), and how many
rows came back (`rows:1`). That one line is the antidote to the "black box" problem - you wrote a Go method
call, and here's the literal SQL it became. 💡 Keep this on the entire time you're learning. The instant a
single call fires five queries, or runs a `SELECT` with no `WHERE`, you'll see it.

## The running example: a blog

Rather than disconnected snippets, this whole guide builds one small, recognizable schema - a **blog** - 
and grows it phase by phase. Three tables, and the relationships between them are exactly the ones every
real app runs into:

```mermaid
flowchart LR
  User -- "writes many" --> Post
  Post -- "has many" --> Comment
  User -- "writes many" --> Comment
```

*What just happened:* the diagram lays out the cast. A **`User`** writes many **`Post`s** and many
**`Comment`s**. A **`Post`** collects many **`Comment`s**. That's a *has-many* relationship in both
directions from `User`, and another from `Post` to `Comment` - the bread and butter of relational data.
Right now they're just boxes and arrows; in Phase 2 we turn `User`, `Post`, and `Comment` into real Go
structs, and from there we'll create rows, query them, update and delete them, and wire up these
relationships with associations and preloading.

For this phase, the win is smaller and concrete: you can open a connection, you've got the logger on, and
you hold the mental model. That's the foundation everything else stands on.

## Recap

1. **GORM is Go's de-facto ORM** - you describe tables as structs and it writes the SQL for CRUD,
   relationships, and migrations, so you skip the hand-written query boilerplate.
2. **The real cost is invisible SQL.** The cure is GORM's logger: turn it on while learning so you see
   the exact query behind every call.
3. **The mental model:** a struct is a table, a `*gorm.DB` is the query you chain, and GORM generates the
   SQL - and you can always drop to raw SQL with `db.Raw`/`db.Exec`.
4. **Install two pieces** - `gorm.io/gorm` plus a driver (`gorm.io/driver/sqlite` here) - then
   `gorm.Open(sqlite.Open("blog.db"), &gorm.Config{})` returns your long-lived `*gorm.DB`.
5. **Open once at startup**, check the error, and pass `db` around; never call `gorm.Open` per request.
6. The guide builds one **blog** schema - `User`, `Post`, `Comment` - phase by phase.

## Quick check

Three questions on the framing that has to stick before Phase 2:

```quiz
[
  {
    "q": "In GORM's mental model, what is a `*gorm.DB`?",
    "choices": [
      "The handle to the database that you chain query methods on",
      "A single row fetched from a table",
      "The raw SQLite file on disk",
      "A struct that maps directly to one table"
    ],
    "answer": 0,
    "explain": "`gorm.Open` returns a `*gorm.DB` - your long-lived handle to the database and the value you chain methods on, like `db.Where(...).Find(...)`. A struct maps to a table; the `*gorm.DB` is the query builder."
  },
  {
    "q": "Why is enabling `logger.Default.LogMode(logger.Info)` such a good habit while learning GORM?",
    "choices": [
      "It prints the exact SQL behind every call, so the ORM stops being a black box",
      "It makes queries run faster by caching them",
      "It is required or GORM will refuse to connect",
      "It automatically rewrites slow queries for you"
    ],
    "answer": 0,
    "explain": "The real cost of an ORM is that you stop seeing your SQL. Logging at Info level prints every generated statement (plus timing and row counts), so you catch surprises - like one call firing several queries - immediately."
  },
  {
    "q": "What two packages do you install to use GORM with SQLite?",
    "choices": [
      "`gorm.io/gorm` and `gorm.io/driver/sqlite`",
      "Only `gorm.io/gorm` - drivers are built in",
      "`gorm.io/gorm` and `database/sql`",
      "`gorm.io/orm` and `gorm.io/sqlite`"
    ],
    "answer": 0,
    "explain": "GORM is the core library plus a database-specific driver. For SQLite that's `gorm.io/gorm` and `gorm.io/driver/sqlite`; switching to Postgres or MySQL means swapping the driver for `gorm.io/driver/postgres` or `gorm.io/driver/mysql`."
  }
]
```


---

# Models & Auto-Migration

In [Phase 1](01-what-gorm-is.md) you opened a `*gorm.DB` and watched it log SQL. Now we give it
something to talk about: a table - and here's the one idea that makes the rest of GORM click.

## The mental model: a struct *is* the table

Stop thinking of "a struct" and "a table" as two things you have to keep in sync by hand. In GORM
they're the same thing seen from two sides. The struct is how Go sees the table; the table is how
the database stores the struct.

- Each **field** becomes a **column**.
- Each **struct tag** adds a **constraint** to that column (size, not-null, unique, an index).
- The **struct name** decides the **table name** (`User` → `users`).
- And one call - `AutoMigrate` - makes the real database **match** the struct you wrote.

> 💡 Once you hold "the struct is the source of truth, the database is its shadow," GORM stops feeling
> like two parallel systems you have to babysit. You edit the struct; you re-run AutoMigrate; the
> table catches up.

We'll build the **blog** schema this whole guide uses. It has three tables - users, posts, comments - 
and we start with `User`.

## `gorm.Model`: the four fields you almost always want

Most tables need a primary key and some timestamps. Typing those into every struct gets old, so GORM
ships a tiny struct you **embed** to get them for free: `gorm.Model`.

```go
type User struct {
    gorm.Model
    Name  string `gorm:"size:100;not null"`
    Email string `gorm:"uniqueIndex;not null"`
}
```

*What just happened:* By embedding `gorm.Model` on the first line, `User` silently gained four fields
before `Name` and `Email` even appear:

| Field | Type | What it does |
|-------|------|--------------|
| `ID` | `uint` | The primary key. Auto-increments. You almost never set it by hand. |
| `CreatedAt` | `time.Time` | GORM stamps it the moment the row is inserted. |
| `UpdatedAt` | `time.Time` | GORM re-stamps it on every save. |
| `DeletedAt` | `gorm.DeletedAt` | Enables **soft delete** - a "deleted" row sticks around but disappears from queries. (Full story in [Phase 5](05-update-and-delete.md).) |

So `CreatedAt` and `UpdatedAt` are managed *for* you - you don't write code to maintain them. That
one embed is why most GORM models start with `gorm.Model`.

> 📝 `gorm.Model` is plain Go embedding, not magic. It's literally a struct with those four fields,
> and embedding it promotes them onto `User`. You could copy-paste the four fields instead and get
> the identical result.

## A tour of field tags

The backtick string after a field - ``gorm:"..."`` - is a **struct tag**. GORM reads it to learn how
that column should be shaped. Multiple settings are separated by semicolons. Here are the ones you'll
reach for constantly:

```go
type User struct {
    gorm.Model
    Name      string `gorm:"size:100;not null"`        // VARCHAR(100), required
    Email     string `gorm:"uniqueIndex;not null"`     // unique index, required
    Username  string `gorm:"size:50;index"`            // plain (non-unique) index
    Role      string `gorm:"size:20;default:'member'"` // default value if none given
    Bio       string `gorm:"column:about_me"`          // override the column name
    avatarRaw []byte `gorm:"-"`                         // ignored entirely - no column
}
```

*What just happened:* each tag maps to one piece of SQL:

- `size:100` → the column's max length (`VARCHAR(100)`). Default for strings is often `VARCHAR(255)`.
- `not null` → a `NOT NULL` constraint; inserting a row without it errors.
- `uniqueIndex` → a unique index, so two users can't share an email. Use `index` for a plain,
  non-unique index (faster lookups, duplicates allowed).
- `default:'member'` → the column's `DEFAULT`. If you create a user without a role, the DB fills in
  `member`.
- `column:about_me` → override GORM's auto-generated column name. Now the Go field is `Bio` but the
  column is `about_me`.
- `-` → "this field is not a column." GORM skips it entirely. (Note `avatarRaw` is lowercase, so it's
  also unexported - handy for internal scratch fields.)

> ⚠️ A struct tag is a single backtick string with **no commas**, only semicolons between settings.
> ``gorm:"size:100, not null"`` is a classic typo - that comma makes GORM misread the second setting.

There are two more worth naming. `primaryKey` marks a field as *the* primary key (you'll see it below
when we skip `gorm.Model`), and you can combine settings freely: ``gorm:"size:255;not null;index"``.

## Naming conventions: where table and column names come from

You didn't write a table name anywhere. GORM derives it, and the rules are worth memorizing because
they're predictable:

- **Struct → table:** the name is **snake_case** and **pluralized**. `User` → `users`,
  `BlogPost` → `blog_posts`, `Comment` → `comments`.
- **Field → column:** **snake_case**, singular. `CreatedAt` → `created_at`, `Name` → `name`.

When the convention doesn't fit - say your table is legacy and called `tbl_users` - override the whole
table name with a `TableName()` method:

```go
func (User) TableName() string {
    return "blog_users"
}
```

*What just happened:* GORM checks for a `TableName() string` method on your model. If it finds one, it
uses that string verbatim instead of pluralizing. Now every query for `User` hits `blog_users`. (The
receiver `(User)` has no name because we don't use it - we only need the method to exist.)

## `AutoMigrate`: make the database match the structs

You have structs. The database has nothing yet. `AutoMigrate` bridges the gap: hand it your models and
it creates the tables, columns, indexes, and foreign keys to match.

```go
err := db.AutoMigrate(&User{}, &Post{}, &Comment{})
if err != nil {
    log.Fatal("migration failed:", err)
}
```

*What just happened:* GORM inspected each struct, compared it to the live database, and issued the SQL
needed to make reality match your code. On a fresh database that means three `CREATE TABLE` statements.
With logging on (from Phase 1), you'd see GORM emit something like this for `User`:

```sql
CREATE TABLE `users` (
  `id` integer PRIMARY KEY AUTOINCREMENT,
  `created_at` datetime,
  `updated_at` datetime,
  `deleted_at` datetime,
  `name` varchar(100) NOT NULL,
  `email` text NOT NULL
);
CREATE UNIQUE INDEX `idx_users_email` ON `users`(`email`);
CREATE INDEX `idx_users_deleted_at` ON `users`(`deleted_at`);
```

*What just happened:* every piece traces back to the struct. `id`/`created_at`/`updated_at`/`deleted_at`
came from `gorm.Model`. `name` is `NOT NULL` and length-capped because of its tags. The unique index on
`email` is your `uniqueIndex` tag. GORM even indexes `deleted_at` on its own, because that's the column
soft-delete filters on. The struct really *is* the table - you're reading your own tags back as SQL.

Run `AutoMigrate` again with no changes and GORM does nothing - it's safe to call on every startup.
Add a field to the struct and re-run, and GORM issues an `ALTER TABLE ... ADD COLUMN` to catch up.

> ⚠️ **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.** Rename `Email` to `EmailAddress` and AutoMigrate adds a *new*
> `email_address` column - the old `email` column and its data just sit there. That's a feature in
> dev (you can't accidentally nuke data) and a limitation in production (it can't express renames,
> drops, or careful type changes). For real, ordered, reversible schema changes you want a proper
> migration tool - [Phase 8](08-transactions-hooks-migrations.md) covers when and why.

So the practical rule: **AutoMigrate is great for development and getting started, not a complete
migration strategy.** Lean on it now; graduate from it later.

## A model *without* `gorm.Model`

`gorm.Model` is a convenience, not a requirement. If you don't want the timestamps or soft-delete - 
say a small lookup table - define your own primary key and skip the embed:

```go
type Tag struct {
    ID   uint   `gorm:"primaryKey"`
    Name string `gorm:"size:50;uniqueIndex;not null"`
}
```

*What just happened:* with no `gorm.Model`, `Tag` has exactly two columns: `id` and `name`. The
`primaryKey` tag tells GORM that `ID` is the primary key (GORM also assumes a `uint` field named `ID`
is the PK by default, so here the tag is explicit insurance). No `created_at`, no `updated_at`, no
soft-delete - just the columns you declared. Use this when the four `gorm.Model` fields would be dead
weight.

With `User` defined and migrated, the table exists and is waiting for rows. Next we put data in and
read it back.

## Recap

- **A struct is the table.** Fields become columns, struct tags add constraints, the struct name sets
  the table name.
- **Embed `gorm.Model`** to get `ID`, `CreatedAt`, `UpdatedAt` (auto-managed), and `DeletedAt`
  (enables soft delete) without writing them yourself.
- **Field tags** shape columns: `size`, `not null`, `uniqueIndex`/`index`, `default`, `column:` to
  rename, `-` to ignore. Separate settings with **semicolons, not commas**.
- **Naming is automatic:** `User` → table `users`, `CreatedAt` → column `created_at`; override the
  table name with a `TableName()` method.
- **`AutoMigrate` makes the DB match the structs** - creating tables and adding missing
  columns/indexes - but it's **additive only**: it never drops or destructively retypes. Great in
  dev, not a full migration tool ([Phase 8](08-transactions-hooks-migrations.md)).
- You can **skip `gorm.Model`** and declare your own `primaryKey` when you don't want timestamps or
  soft delete.

## Quick check

```quiz
[
  {
    "q": "What does embedding gorm.Model add to your struct?",
    "choices": ["Only an ID field", "ID, CreatedAt, UpdatedAt, and DeletedAt", "A TableName method", "Nothing until you run AutoMigrate"],
    "answer": 1,
    "explain": "gorm.Model embeds four fields: ID (primary key), the auto-managed CreatedAt and UpdatedAt timestamps, and DeletedAt which enables soft delete."
  },
  {
    "q": "You rename a struct field and re-run AutoMigrate. What happens to the old column?",
    "choices": ["It is renamed to match", "It is dropped automatically", "It stays - a new column is added alongside it", "AutoMigrate refuses to run"],
    "answer": 2,
    "explain": "AutoMigrate is additive only. It adds a new column for the renamed field and leaves the old column (and its data) untouched. Real renames need a proper migration tool."
  },
  {
    "q": "By default, which table does a struct named BlogPost map to?",
    "choices": ["BlogPost", "blogpost", "blog_posts", "blogposts"],
    "answer": 2,
    "explain": "GORM converts the struct name to snake_case and pluralizes it: BlogPost becomes blog_posts. Override it with a TableName() method if needed."
  }
]
```


---

# Create & Read

You've got a struct that maps to a table, and `AutoMigrate` has built that table for you. Now comes the part that makes an ORM feel like magic the first time: you hand GORM a Go value, and a row appears in the database. Then you ask for it back, and it lands in a struct, fully populated. No `INSERT`, no `SELECT`, no scanning columns into fields by hand.

Staying in control is a matter of keeping one picture in your head.

## The mental model

There are two directions of traffic, and only two:

> 💡 **`Create` pushes a struct into the database and fills in the generated fields. The finders (`First` / `Take` / `Last` / `Find`) pull rows out of the database into your structs.** Write goes one way, read comes back the other.

That's it. When you write, GORM doesn't just fire off an `INSERT` and forget - it reads the auto-generated primary key (and timestamps) back out and writes them *into the struct you passed*. When you read, GORM runs a `SELECT`, takes the columns, and pours them into the destination you handed it. The struct is the shape; `*gorm.DB` is the pump moving rows in and out.

And because you turned on the logger back in [Phase 1](01-what-gorm-is.md), you can *watch* the SQL each call generates. That's your superpower throughout this guide: every GORM call here is shown with the SQL it produces, so the ORM never becomes a black box.

Here's the model we'll use for the whole phase - the start of our **blog**:

```go
type User struct {
	gorm.Model        // ID, CreatedAt, UpdatedAt, DeletedAt
	Name  string
	Email string
}
```

*What just happened:* We embedded `gorm.Model`, so this struct already has an `ID uint` primary key plus the timestamp and soft-delete fields from [Phase 2](02-models-and-migration.md). We only added the two columns that are actually ours: `Name` and `Email`.

## Inserting a row with `Create`

You build a Go value, take its address, and pass it to `Create`:

```go
user := User{Name: "Ada", Email: "ada@example.com"}

db.Create(&user)

fmt.Println("new ID is", user.ID)        // e.g. 1
fmt.Println("created at", user.CreatedAt) // e.g. 2026-06-23 10:04:00
```

*What just happened:* Notice we never set `user.ID` - it was `0` going in. After `Create` returns, `user.ID` is `1`. GORM ran the `INSERT`, the database assigned the primary key, and GORM **wrote that value back into the struct**. Same story for `CreatedAt` and `UpdatedAt`. The `&` matters: you pass a *pointer* so GORM can mutate your struct.

The SQL the logger prints looks like this:

```sql
INSERT INTO `users` (`created_at`,`updated_at`,`deleted_at`,`name`,`email`)
VALUES ('2026-06-23 10:04:00','2026-06-23 10:04:00',NULL,'Ada','ada@example.com')
RETURNING `id`
```

*What just happened:* GORM filled in `created_at`/`updated_at` for you, left `deleted_at` as `NULL` (the row isn't deleted), and used `RETURNING id` to grab the generated primary key - that's the value it copied back into `user.ID`.

### Checking whether it worked

`Create` returns a `*gorm.DB`, and the two fields you care about on it are `Error` and `RowsAffected`:

```go
result := db.Create(&user)
if result.Error != nil {
	log.Fatalf("insert failed: %v", result.Error)
}
fmt.Println("rows inserted:", result.RowsAffected) // 1
```

*What just happened:* GORM doesn't `panic` on a failed insert - a duplicate email, a broken connection, a constraint violation all surface as `result.Error`. Get into the habit of checking it. `result.RowsAffected` tells you how many rows the statement touched, which is `1` for a single insert.

> ⚠️ A common surprise: `Create` does **not** return an error for `nil` here, but it *will* error if a unique constraint or NOT NULL constraint is violated. Don't assume success - check `result.Error` every time you write.

### Inserting many rows at once

Pass a *slice* and GORM batches the insert:

```go
users := []User{
	{Name: "Linus", Email: "linus@example.com"},
	{Name: "Grace", Email: "grace@example.com"},
	{Name: "Edsger", Email: "edsger@example.com"},
}

result := db.Create(&users)
fmt.Println("rows inserted:", result.RowsAffected) // 3
fmt.Println("first new ID:", users[0].ID)          // 2
```

*What just happened:* One call, one `INSERT` statement with multiple value rows. GORM wrote the generated `ID` back into *each* element of the slice, so `users[0].ID`, `users[1].ID`, and so on are all populated. `RowsAffected` is `3`. This is far faster than calling `Create` in a loop - let GORM batch it.

## Reading rows back: the four finders

Now the other direction. GORM gives you four finder methods, and the difference between them is mostly *how many rows* and *in what order*. Learn these four and you've covered the vast majority of reads you'll ever write.

### `First` - one row, by primary key

Give it a struct pointer and an ID, and `First` fetches that row:

```go
var user User
db.First(&user, 1)   // find the user with ID = 1

fmt.Println(user.Name) // "Ada"
```

```sql
SELECT * FROM `users` WHERE `users`.`id` = 1 ORDER BY `users`.`id` LIMIT 1
```

*What just happened:* `First` orders by the primary key and takes the first row - here, the one matching `id = 1`. The second argument (`1`) is shorthand for "look this up by primary key." GORM scanned the single returned row into `user`. Note the `ORDER BY id` and `LIMIT 1`: that ordering is what makes it "first."

### `First` - one row, by a condition

Drop the ID and pass a condition string with `?` placeholders instead:

```go
var user User
db.First(&user, "email = ?", "grace@example.com")

fmt.Println(user.Name) // "Grace"
```

```sql
SELECT * FROM `users` WHERE email = 'grace@example.com' ORDER BY `users`.`id` LIMIT 1
```

*What just happened:* Same `First`, but now the lookup is "first row where `email` matches." The `?` is a placeholder; the `"grace@example.com"` argument fills it in safely (more on *why* that matters at the end). You still get `ORDER BY id LIMIT 1`, so among matching rows you get the lowest-ID one.

### `Take` - one row, no ordering

When you just want *some* row and don't care which, `Take` skips the `ORDER BY`:

```go
var user User
db.Take(&user)
```

```sql
SELECT * FROM `users` LIMIT 1
```

*What just happened:* No `ORDER BY` clause - GORM grabs whatever row the database hands back first. It's marginally cheaper than `First` because the database doesn't have to sort. Reach for `Take` when "any one row" is genuinely fine; reach for `First` when you want a deterministic "the first one."

(`Last` is the mirror of `First`: `db.Last(&user)` orders by primary key *descending* and takes one - the newest row by ID. Same shape, opposite end.)

### `Find` - all the rows, into a slice

When you want more than one row, pass a *slice* pointer to `Find`:

```go
var users []User
db.Find(&users)

fmt.Println("got", len(users), "users") // got 4 users
```

```sql
SELECT * FROM `users`
```

*What just happened:* No `LIMIT`, no `ORDER BY` - `Find` reads *every* row in the table and appends each one to the slice. (You'll add `Where`, `Order`, and `Limit` to narrow and shape this in [Phase 4](04-querying.md); right now it's the firehose.) `Find` works with a condition too: `db.Find(&users, "name = ?", "Ada")` returns all matching rows.

## The one gotcha that bites everyone: zero rows

Here's the difference between `First` and `Find` that trips up every newcomer, so let's make it stick.

> ⚠️ When **no row matches**: `First` (and `Take` and `Last`) treat that as an **error** - they return `gorm.ErrRecordNotFound`. But `Find` treats it as a perfectly normal result - you get an **empty slice and a `nil` error**.

That asymmetry is intentional. Asking for "the user with this ID" and getting nothing back is usually a real problem worth handling (a 404). Asking for "all users named X" and getting nothing back is a normal, expected answer (zero results).

So you check them differently. For a single-record `First`, test for the not-found error explicitly:

```go
var user User
result := db.First(&user, "email = ?", "nobody@example.com")

if errors.Is(result.Error, gorm.ErrRecordNotFound) {
	// no such user - in a web handler, this is your 404
	http.Error(w, "user not found", http.StatusNotFound)
	return
}
if result.Error != nil {
	// some *other* error - a real database failure
	http.Error(w, "internal error", http.StatusInternalServerError)
	return
}
// here, user is populated
```

*What just happened:* `errors.Is(result.Error, gorm.ErrRecordNotFound)` is the canonical "not found" check - use `errors.Is`, not `==`, so it survives error wrapping. That branch maps cleanly to an HTTP 404. A *different* non-nil error means something actually broke (connection dropped, bad SQL), which is a 500. This three-way split - not found / other error / success - is the bread-and-butter shape of a GORM read in a web handler.

For `Find`, there's nothing to special-case - just check `len`:

```go
var users []User
result := db.Find(&users, "name = ?", "Ghost")

if result.Error != nil {
	// a real failure; "no matches" is NOT one of these
	log.Println(result.Error)
}
fmt.Println("matches:", len(users)) // 0 - and result.Error is nil
```

*What just happened:* Zero matches gave us an empty slice and a `nil` error. If you write `if result.Error != nil` expecting it to catch "no users named Ghost," it never fires - the empty slice *is* the answer. Check `len(users) == 0` if "no results" needs handling.

> 📝 Quick memory hook: **singular finder (`First`/`Take`/`Last`) → errors on empty. Plural finder (`Find`) → empty slice, no error.** One row missing is an exception; zero matching rows is just a count.

## A safety note: those `?` placeholders

You saw `db.First(&u, "email = ?", email)` a few times. The `?` is not optional styling - it's the thing standing between you and SQL injection.

Never build a condition by gluing strings together:

```go
// ⚠️ DANGER - never do this
db.First(&u, fmt.Sprintf("email = '%s'", userInput))
```

*What just happened:* If `userInput` is `' OR '1'='1`, that string becomes `email = '' OR '1'='1'` - a condition that matches every row. Worse inputs can read or destroy data you never meant to expose. You hand-built an injection hole.

Do this instead:

```go
// ✅ SAFE - parameterized
db.First(&u, "email = ?", userInput)
```

*What just happened:* GORM sends the SQL and the value to the database *separately*. The driver treats `userInput` as pure data - a value to compare against, never as SQL to execute. Even `' OR '1'='1` is just a (very odd) string it looks for and doesn't find. Always pass values through `?`; never interpolate them into the query string.

## Recap

- **`Create(&value)` inserts** and writes the generated `ID`, `CreatedAt`, and `UpdatedAt` *back into your struct* - pass a pointer so it can.
- Check `result.Error` after every write (GORM won't panic), and use `result.RowsAffected` to see how many rows changed. Pass a **slice** to `Create` for a batched multi-row insert.
- The four finders: **`First`** (one, by PK, ordered), **`First` with a condition**, **`Take`** (one, unordered), **`Last`** (one, newest by PK), and **`Find`** (all matching, into a slice).
- **`First`/`Take`/`Last` return `gorm.ErrRecordNotFound` on zero rows; `Find` returns an empty slice and `nil` error.** Use `errors.Is(err, gorm.ErrRecordNotFound)` and map it to a 404.
- Always pass values through **`?` placeholders** - they're parameterized and injection-safe. Never `fmt.Sprintf` user input into a query.

## Quick check

```quiz
[
  {
    "q": "After `db.Create(&user)` succeeds on a struct embedding gorm.Model, what is true of `user.ID`?",
    "choices": ["It is still 0 - you must SELECT to learn it", "GORM wrote the generated primary key back into it", "It holds the number of rows affected", "It is only set if you call db.Save next"],
    "answer": 1,
    "explain": "Create reads the generated primary key (via RETURNING) and copies it back into the struct you passed - which is why you pass a pointer."
  },
  {
    "q": "You call `db.Find(&users, \"name = ?\", \"Ghost\")` and no rows match. What do you get?",
    "choices": ["result.Error is gorm.ErrRecordNotFound", "A panic", "An empty slice and a nil error", "users is nil and result.Error is set"],
    "answer": 2,
    "explain": "Find treats zero matches as a normal result: an empty slice with no error. Only First/Take/Last return ErrRecordNotFound on no rows."
  },
  {
    "q": "Which line safely looks up a user by an email that came from user input?",
    "choices": ["db.First(&u, fmt.Sprintf(\"email = '%s'\", in))", "db.First(&u, \"email = ?\", in)", "db.First(&u, \"email = \" + in)", "db.Take(&u, in)"],
    "answer": 1,
    "explain": "The ? placeholder is parameterized: GORM sends the value separately from the SQL, so it can't be interpreted as code. String interpolation opens a SQL injection hole."
  }
]
```


---

# Querying

Here's the one idea that makes everything in this phase click: **a GORM chain doesn't run anything until you tell it to**. When you write `db.Where(...).Order(...).Limit(...)`, you're not hitting the database - you're *assembling* a query, clause by clause, in memory, and each method bolts one more piece onto a query that's still just sitting there. Nothing touches the database until you call a **finalizer** - `Find`, `First`, `Count`, and friends - which is the moment GORM turns your assembled chain into actual SQL and sends it over the wire.

> 💡 This is exactly how a SQL query builder works in any language. `Where` ≈ the `WHERE` clause, `Order` ≈ `ORDER BY`, `Limit` ≈ `LIMIT`. You're describing a SQL statement in Go syntax, and the finalizer compiles and runs it. Hold that mapping and GORM stops feeling like magic.

Throughout this phase, picture the SQL each chain produces. We'll show it side by side so the translation becomes second nature.

## The chain builds; the finalizer runs

```go
// This line does NOT hit the database. It returns a *gorm.DB
// carrying a half-built query.
query := db.Where("published = ?", true).Order("created_at desc")

// THIS line runs it - Find is the finalizer.
var posts []Post
query.Find(&posts)
```

```sql
SELECT * FROM posts WHERE published = true ORDER BY created_at desc;
```

*What just happened:* The first statement assembled a `WHERE` and an `ORDER BY` and handed back a `*gorm.DB` with that state stored inside. No SQL ran. Only `Find(&posts)` triggered the round-trip: GORM compiled the accumulated clauses into one `SELECT`, ran it, and scanned each row into the `posts` slice. Common finalizers: `Find` (many rows), `First`/`Take`/`Last` (one row), `Count` (a number), plus `Create`/`Save`/`Delete` from the other phases.

## `Where`, in all its forms

`Where` is where most of your filtering lives. The workhorse form is a **string with `?` placeholders** plus arguments - and you should *always* use placeholders, never string concatenation, because GORM parameterizes them and the database escapes them for you (no SQL injection).

```go
var posts []Post

// Single condition
db.Where("view_count > ?", 1000).Find(&posts)

// Multiple conditions in one string
db.Where("view_count > ? AND published = ?", 1000, true).Find(&posts)

// IN - pass a slice, GORM expands it
db.Where("id IN ?", []uint{1, 2, 3}).Find(&posts)

// LIKE - the % wildcards go in the argument, not the SQL
db.Where("title LIKE ?", "%go%").Find(&posts)
```

```sql
SELECT * FROM posts WHERE view_count > 1000;
SELECT * FROM posts WHERE view_count > 1000 AND published = true;
SELECT * FROM posts WHERE id IN (1,2,3);
SELECT * FROM posts WHERE title LIKE '%go%';
```

*What just happened:* Each `Where` became a `WHERE` clause. For `IN`, you hand GORM a Go slice and it expands it into `(1,2,3)` - note there are no parentheses in your Go string; GORM adds them. For `LIKE`, the `%` wildcards belong in the *argument value* (`"%go%"`), so they pass through the placeholder safely. The `?` placeholders mean the values are sent separately from the SQL text, which is what keeps you safe from injection.

### Chaining, `Or`, and `Not`

Chain multiple `Where` calls and GORM joins them with **AND**. For OR, use `Or`; to negate, use `Not`.

```go
// Chained Where = AND
db.Where("published = ?", true).Where("view_count > ?", 500).Find(&posts)

// Or
db.Where("view_count > ?", 1000).Or("featured = ?", true).Find(&posts)

// Not
db.Not("published = ?", false).Find(&posts)
```

```sql
SELECT * FROM posts WHERE published = true AND view_count > 500;
SELECT * FROM posts WHERE view_count > 1000 OR featured = true;
SELECT * FROM posts WHERE NOT (published = false);
```

*What just happened:* Two `Where`s in a row produced `AND` - that's the default glue. `Or` switched the connector to `OR` for that fragment, and `Not` wrapped its condition in a negation. When you start mixing `AND` and `OR`, watch the generated SQL closely: operator precedence in the database may not group things the way you assumed, and a stray `OR` can quietly widen your result set.

## Struct vs map conditions - the zero-value trap

GORM lets you pass a **struct** as a condition: it reads the non-empty fields and builds equality checks. It's clean and type-safe - until it silently drops a field on you.

```go
// Struct condition - matches on the fields you set
db.Where(&User{Name: "Alice", Active: true}).Find(&users)
```

```sql
SELECT * FROM users WHERE name = 'Alice' AND active = true;
```

*What just happened:* GORM walked the struct, found `Name` and `Active` set, and turned each into an `=` condition. Looks great. Now here's the trap. ⚠️

```go
// You WANT: everyone whose age is 0. You get: everyone.
db.Where(&User{Age: 0}).Find(&users)
```

```sql
SELECT * FROM users;   -- the Age condition vanished!
```

*What just happened:* **Struct conditions ignore zero-value fields.** `Age: 0` is the zero value for an `int`, so GORM can't tell "I deliberately want age 0" apart from "I didn't set age," and it leaves the condition out entirely. Same for `Name: ""`, `Active: false`, `nil` pointers - all silently dropped. The query you *thought* filtered returns the whole table.

The fix when you genuinely need to match a zero value: use a **map**, where a present key always becomes a condition.

```go
// Map condition - the key is there, so the condition is there
db.Where(map[string]any{"age": 0}).Find(&users)
```

```sql
SELECT * FROM users WHERE age = 0;
```

*What just happened:* A map carries no notion of "zero means unset" - if the key `"age"` is in the map, GORM emits `age = 0`, full stop. Rule of thumb: structs for the common case (non-zero values, type safety); maps the moment a zero, empty string, or false is a real value you need to filter on.

## `Order`, `Limit`, `Offset`, `Select`, `Count`

These map straight onto their SQL clauses. `Order` sorts, `Limit` caps the row count, and `Offset` skips rows - together they give you pagination.

```go
// Page 3 of 10-per-page: skip 20, take 10, newest first
var posts []Post
db.Order("created_at desc").Limit(10).Offset(20).Find(&posts)
```

```sql
SELECT * FROM posts ORDER BY created_at desc LIMIT 10 OFFSET 20;
```

*What just happened:* `Order` set the sort, `Limit` capped results at 10, and `Offset` skipped the first 20 - so this is page 3 (offsets 0, 10, 20...). The general pagination formula is `Offset((page - 1) * pageSize).Limit(pageSize)`. Always pair pagination with an `Order`; without a stable sort, "page 2" isn't guaranteed to exclude what "page 1" already showed.

Use `Select` to fetch only the columns you need, and `Count` to get a number instead of rows.

```go
// Only pull two columns
var users []User
db.Select("name", "email").Find(&users)

// Count rows matching a condition - note Model + Count
var n int64
db.Model(&User{}).Where("active = ?", true).Count(&n)
```

```sql
SELECT name, email FROM users;
SELECT count(*) FROM users WHERE active = true;
```

*What just happened:* `Select` narrowed the `SELECT` list to two columns - handy when a table is wide and you only need a couple of fields. `Count` is a finalizer that returns a count rather than scanning rows; because there's no slice to infer the table from, you tell GORM the table with `Model(&User{})`, and the result lands in an `int64` you pass by pointer.

## Scopes - reusable query fragments

Once you've written `Where("published = ?", true)` for the fifth time, pull it into a **scope**: a function that takes a `*gorm.DB`, adds some clauses, and returns it. You then drop it into any chain with `Scopes(...)`.

```go
// A scope is just: func(*gorm.DB) *gorm.DB
func Published(db *gorm.DB) *gorm.DB {
    return db.Where("published = ?", true)
}

func Popular(db *gorm.DB) *gorm.DB {
    return db.Where("view_count > ?", 1000)
}

// Compose them into a chain - order them however reads best
var posts []Post
db.Scopes(Published, Popular).Order("created_at desc").Find(&posts)
```

```sql
SELECT * FROM posts
WHERE published = true AND view_count > 1000
ORDER BY created_at desc;
```

*What just happened:* `Published` and `Popular` each take the in-progress `*gorm.DB`, tack on a `Where`, and hand it back. `Scopes(Published, Popular)` ran both against the chain before the finalizer, so their conditions joined with `AND` - exactly as if you'd written the two `Where`s inline. Now "published and popular" is a named, testable, reusable thing you can compose anywhere instead of copy-pasting filter strings.

> 💡 Scopes are also where pagination logic usually lives - a `Paginate(page, size)` scope keeps every list endpoint consistent. Keep the Phase 1 SQL logger on while you build them so you can *see* what each scope adds to the statement. The moment a chain produces SQL you didn't expect - a missing condition, a surprise full scan - you've caught a bug before it ships. That habit of reading the generated SQL is the same skill that saves you in [Why Is My Query Slow?](/guides/why-is-my-query-slow).

## Recap

- A chain **builds** a query lazily; nothing runs until a **finalizer** (`Find`, `First`, `Count`, ...) compiles and executes it.
- `Where` takes a string with `?` placeholders plus args - always parameterize. Chained `Where` = AND; use `Or` and `Not` for the rest. `IN` takes a slice; `LIKE` puts the `%` in the argument.
- **Struct conditions silently ignore zero-value fields** (`0`, `""`, `false`, `nil`). When a zero value is a real filter, use a `map[string]any` instead.
- `Order` + `Limit` + `Offset` give pagination (`Offset((page-1)*size).Limit(size)`); always pair with an `Order`. `Select` narrows columns; `Count` needs `Model(&T{})` and an `int64`.
- **Scopes** are `func(*gorm.DB) *gorm.DB` fragments composed via `Scopes(...)` - reusable, testable filters. Keep the SQL logger on to confirm each chain generates what you expect.

Check your grip on the lazy chain and the zero-value trap:

```quiz
[
  {
    "q": "When does `db.Where(\"age > ?\", 18).Order(\"name\")` actually run SQL against the database?",
    "choices": ["As soon as Where is called", "As soon as Order is called", "Only when a finalizer like Find or Count is called", "When the *gorm.DB variable goes out of scope"],
    "answer": 2,
    "explain": "The chain only builds the query in memory. SQL runs when a finalizer (Find, First, Count, etc.) compiles and executes it."
  },
  {
    "q": "What does `db.Where(&User{Age: 0}).Find(&users)` return?",
    "choices": ["Only users with age 0", "All users - the zero-value Age condition is dropped", "A compile error", "No users at all"],
    "answer": 1,
    "explain": "Struct conditions ignore zero-value fields, so `Age: 0` is omitted and the query has no WHERE. Use map[string]any{\"age\": 0} to match a zero value."
  },
  {
    "q": "What is a GORM scope?",
    "choices": ["A struct tag that limits column access", "A function `func(*gorm.DB) *gorm.DB` that adds clauses and is composed via Scopes(...)", "A transaction boundary", "A way to scope a connection to one goroutine"],
    "answer": 1,
    "explain": "A scope takes the in-progress *gorm.DB, adds query clauses, and returns it. You compose scopes into a chain with Scopes(...)."
  }
]
```


---

# Update & Delete

You've created rows and queried them back. Now you need to change them and remove them - and this is where GORM has two surprises that catch nearly everyone the first time. Once you hold the mental model, both stop being surprises and start being predictable.

Here's the model, and it's the whole phase in one sentence: **an update targets a row by its primary key (or a `Where` you add) and writes the changed columns; a delete - on a model with `gorm.Model` - hides the row by stamping `deleted_at` instead of removing it.** Hold those two ideas. Every confusing thing below is a consequence of one of them.

> 📝 We're still on the **blog** schema from earlier phases: a `User` and a `Post`, both embedding `gorm.Model` (so they each have an `ID`, timestamps, and - crucially for this phase - a `DeletedAt`).

## Updating one field: `Update`

The simplest change. You have a record loaded, you want to change one column:

```go
var user User
db.First(&user, 1) // load the user with ID = 1

db.Model(&user).Update("name", "Bob")
```

```sql
UPDATE `users` SET `name`='Bob',`updated_at`='2026-06-23 ...' WHERE `id` = 1
```

*What just happened:* `Model(&user)` tells GORM "the target is this row." Because `user` has its `ID` set, GORM scopes the `UPDATE` to `WHERE id = 1` - it doesn't touch any other row. It also bumped `updated_at` for free, because the model embeds `gorm.Model`.

## Updating several fields: `Updates` - and THE TRAP

Now you want to change a few columns at once. The natural reach is to pass a struct:

```go
db.Model(&user).Updates(User{Name: "Bob", Age: 0})
```

```sql
UPDATE `users` SET `name`='Bob',`updated_at`='2026-06-23 ...' WHERE `id` = 1
```

*What just happened:* Look hard at that SQL. You asked to set `name = 'Bob'` **and** `age = 0`. GORM set the name and **silently dropped the age.** The row's age is unchanged.

> ⚠️ **This is the single most important thing in this phase.** When you pass a **struct** to `Updates`, GORM only updates **non-zero fields**. `Age: 0` is the zero value for an `int`, so GORM can't tell "the user genuinely set 0" apart from "the user left this field blank" - and it assumes blank. Same for `""`, `false`, `nil`. Your update vanishes with no error.

Why does GORM do this? Because a struct literal can't express "I didn't set this field." A struct always has *all* its fields, each holding *some* value, and for a freshly-built `User{Name: "Bob"}` the `Age` is `0` whether you meant it or not. So GORM plays it safe and skips zeros. Convenient most of the time - until the day a real `0`, `false`, or `""` is exactly the value you need to write.

💡 **The fix: pass a map.** A map only contains the keys you put in it, so there's no ambiguity - GORM writes every key, zero or not:

```go
db.Model(&user).Updates(map[string]any{"name": "Bob", "age": 0})
```

```sql
UPDATE `users` SET `name`='Bob',`age`=0,`updated_at`='2026-06-23 ...' WHERE `id` = 1
```

*What just happened:* This time `age=0` made it into the SQL. The map said "I want these two columns set," and GORM obeyed literally. **Rule of thumb: struct for "update whatever's filled in," map when a zero value must actually land.**

## Writing the whole row: `Save`

`Updates` writes the columns you name. `Save` writes **all** of them - it's a full-row update:

```go
user.Name = "Bob"
user.Age = 0
db.Save(&user)
```

```sql
UPDATE `users` SET `name`='Bob',`age`=0,`email`='bob@blog.dev',`created_at`='...',`updated_at`='...' WHERE `id` = 1
```

*What just happened:* `Save` took the entire `user` struct and wrote every column back, `age=0` included - no zero-value skipping here, because `Save` isn't trying to guess which fields you "meant." It needs the primary key set (which it is, since we loaded the row). Use `Save` when you've mutated a loaded struct in Go and want the database to match it exactly. Reach for `Updates` when you only want to touch specific columns and leave the rest as they are.

## Bulk updates: a `Where`, and the safety block

So far every update hit one row via its PK. To change many rows at once, drop the loaded record and use `Model(&User{})` with a `Where`:

```go
db.Model(&User{}).Where("age < ?", 18).Update("active", false)
```

```sql
UPDATE `users` SET `active`=false,`updated_at`='...' WHERE age < 18
```

*What just happened:* No specific record, no PK - GORM updated every row matching the `Where`. This is the right tool for "deactivate all minors" or "mark every draft as archived."

> ⚠️ But what if you forget the `Where`? `db.Model(&User{}).Update("active", false)` would, taken literally, set `active = false` on **every user in the table.** GORM refuses: it returns `ErrMissingWhereClause` rather than run a global update by accident. This guard has saved more production tables than anyone can count.

If you genuinely mean "every row," you opt in explicitly:

```go
db.Session(&gorm.Session{AllowGlobalUpdate: true}).
    Model(&User{}).
    Update("active", false)
```

*What just happened:* By opening a session with `AllowGlobalUpdate: true`, you've told GORM "yes, I really do mean the whole table." The block lifts for that chain. The fact that you have to say so out loud is the point - a global update should never be something you do by forgetting a clause.

## Deleting: the second big surprise

Now removal. The call looks exactly like what you'd expect:

```go
var user User
db.First(&user, 1)
db.Delete(&user)
```

```sql
UPDATE `users` SET `deleted_at`='2026-06-23 14:02:11' WHERE `id` = 1 AND `users`.`deleted_at` IS NULL
```

*What just happened:* You called `Delete`, and GORM ran an **`UPDATE`** - not a `DELETE`. The row is still sitting in the table; GORM just stamped its `deleted_at` column with the current time.

> ⚠️ This is **soft delete**, and it's automatic for any model that embeds `gorm.Model` (or otherwise has a `DeletedAt gorm.DeletedAt` field). "Deleted" means "marked as deleted," not "gone." People are routinely baffled when a deleted user keeps occupying a unique email or shows up in a raw `SELECT *` - the row never left.

The flip side is the genuinely useful part: every normal query **automatically excludes** soft-deleted rows. Notice the `AND deleted_at IS NULL` GORM quietly added above - it adds that to your `Find`s and `First`s too:

```go
var users []User
db.Find(&users) // the soft-deleted user 1 is NOT in here
```

```sql
SELECT * FROM `users` WHERE `users`.`deleted_at` IS NULL
```

*What just happened:* You didn't ask for `deleted_at IS NULL` - GORM added it because the model is soft-deletable. From your code's perspective the row is gone; it's just recoverable, and it leaves an audit trail. (You can also delete by ID without loading first: `db.Delete(&User{}, 1)`.)

## Seeing and removing soft-deleted rows: `Unscoped`

Sometimes you need to peek behind the curtain - or actually purge a row for real. `Unscoped()` drops the automatic `deleted_at IS NULL` filter:

```go
var users []User
db.Unscoped().Find(&users) // includes soft-deleted rows
```

```sql
SELECT * FROM `users`
```

*What just happened:* No `deleted_at` filter - you see everything, deleted-or-not. This is how you build a "recently deleted" view or recover a row.

And to delete a row **permanently** - a real `DELETE`, gone for good - combine `Unscoped()` with `Delete`:

```go
db.Unscoped().Delete(&user)
```

```sql
DELETE FROM `users` WHERE `id` = 1
```

*What just happened:* `Unscoped()` told GORM "skip the soft-delete machinery," so `Delete` did a true hard delete. The row is now actually removed. Reach for this when you mean it (GDPR erasure, purging test data) - and only then.

## Recap

- **`Update`** changes one column; **`Updates`** changes several. Both target the row by its PK (or a `Where` you add) and bump `updated_at`.
- **The zero-value trap:** `Updates` with a **struct** skips zero fields (`0`, `""`, `false`, `nil`) - they vanish silently. Pass a **map** when a zero value must actually be written.
- **`Save`** writes the entire struct back (a full-row update); use it to make the DB match a mutated Go value exactly.
- **Bulk updates** need a `Where`; a missing one triggers `ErrMissingWhereClause`. Opt into a whole-table update with `Session(&gorm.Session{AllowGlobalUpdate: true})`.
- **Soft delete** is automatic with `gorm.Model`: `Delete` runs an `UPDATE` on `deleted_at`, and normal queries auto-exclude the row - it's hidden, not removed.
- **`Unscoped()`** reveals soft-deleted rows (`Find`) and, with `Delete`, performs a true hard delete.

## Quick check

```quiz
[
  {
    "q": "When does db.Model(&user).Updates(...) write a zero value like age = 0 to the database?",
    "choices": ["Always", "Never", "Only when you pass a map, not a struct", "Only with db.Save"],
    "answer": 2,
    "explain": "A struct can't distinguish an intentional zero from an unset field, so GORM skips zeros. A map only contains the keys you set, so every key - including zeros - is written."
  },
  {
    "q": "Why does db.Model(&User{}).Update(\"active\", false) (no Where) fail by default?",
    "choices": ["false isn't a valid value", "GORM blocks global updates to prevent accidentally changing every row", "Update can't take a literal", "the model has no primary key"],
    "answer": 1,
    "explain": "GORM returns ErrMissingWhereClause to stop you from updating the whole table by accident. Add a Where, or opt in with AllowGlobalUpdate."
  },
  {
    "q": "After db.Delete(&user) on a gorm.Model-backed type, how do you see that row again in a query?",
    "choices": ["db.Find(&users)", "db.Unscoped().Find(&users)", "you can't - it's gone", "db.Save(&user)"],
    "answer": 1,
    "explain": "The row was soft-deleted (deleted_at set), so normal queries exclude it. Unscoped() drops that filter and shows soft-deleted rows."
  }
]
```


---

# Associations

So far every table in our blog has lived alone. A `User` is a `User`, a `Post` is a `Post`, and nothing
ties them together. Real data isn't like that - a post is written *by* a user, a comment belongs *to* a
post, a post is tagged *with* tags. This phase is where the blog stops being a pile of tables and
becomes a connected schema.

If the words "foreign key," "one-to-many," and "join table" feel hazy, pause and read
[Relationships & Keys](/guides/relationships-and-keys) first - this phase assumes you know what a
foreign key *is* and focuses on how GORM lets you express those relationships in Go.

## The mental model: an association is a foreign key plus a Go field that mirrors it

Here's the one idea that makes every relationship type below click. At the database level, a
relationship is always the same thing: **a foreign-key column on one table pointing at another table's
primary key.** Nothing exotic. The `posts` table has a `user_id` column; that column holds the `id` of
the user who wrote the post. That's the whole relationship, in SQL terms.

What GORM adds is a second view of that same fact, written in Go. You describe the relationship **twice**
in your structs:

- the **foreign-key field** (`UserID uint`) - the literal column that stores the link, and
- a **struct field of the related type** (`User User` or `Posts []Post`) - a Go-side handle that *mirrors*
  the link so you can walk it in code.

> 💡 GORM doesn't have a separate "define a relationship" function. It reads the **shape of your structs**
> - a `uint` field named `<Type>ID` next to a field of that type - and infers the relationship from it.
> The struct *is* the schema, exactly like Phase 2 promised. You're not configuring associations; you're
> drawing them with field names.

Hold that and the four relationship types stop being four things to memorize. They're four shapes of the
same foreign-key-plus-mirror idea.

## Belongs-to and has-many: User ↔ Post

The most common relationship in any app: one user writes many posts. This is **one relationship seen from
two ends.** From the post's side it's *belongs-to* (each post belongs to one user). From the user's side
it's *has-many* (each user has many posts). Same foreign key, two viewpoints.

```go
type User struct {
    gorm.Model
    Name  string `gorm:"size:100;not null"`
    Posts []Post // has many: one user, many posts
}

type Post struct {
    gorm.Model
    Title  string `gorm:"size:200;not null"`
    Body   string
    UserID uint // the foreign key - the actual column
    User   User // belongs to: the back-reference
}
```

*What just happened:* we wrote the same link from both directions. `Post.UserID` is the foreign-key
column - a plain `uint` that holds which user owns the row. `Post.User` is the belongs-to back-reference,
a Go field you can read to get the whole owner struct. And `User.Posts` is the has-many side, a slice that
will hold this user's posts. GORM connects all three by **naming convention**: it sees the `[]Post` slice
on `User`, looks on `Post` for a field named `UserID` (`<Owner>` + `ID`), and uses it as the FK. No tags
required - the names do the wiring.

When you run `AutoMigrate(&User{}, &Post{})`, GORM creates the column *and* the constraint:

```sql
CREATE TABLE `posts` (
  `id` integer PRIMARY KEY AUTOINCREMENT,
  `created_at` datetime,
  `updated_at` datetime,
  `deleted_at` datetime,
  `title` varchar(200) NOT NULL,
  `body` text,
  `user_id` integer,
  CONSTRAINT `fk_users_posts` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`)
);
```

*What just happened:* the `user_id` column and the `fk_users_posts` foreign-key constraint both came
straight from your struct shape. AutoMigrate read `User.Posts` and `Post.UserID`, figured out the
direction, and emitted exactly the SQL you'd have written by hand. The relationship you "declared" in Go
is now a real, enforced FK in the database.

> 📝 GORM infers the FK as `<OwnerType>ID` - `UserID` here. If your column is named something else (a
> legacy `author_id`, say), you tell GORM with a tag: ``Posts []Post `gorm:"foreignKey:AuthorID"` `` and a
> matching `AuthorID uint` field. But when you follow the convention, you write zero tags.

## Has-one: User ↔ Profile

Has-one is has-many's quieter sibling: a user has **exactly one** profile, not a slice of them. The shape
is nearly identical - the only difference is that the parent holds a single struct instead of a slice.

```go
type User struct {
    gorm.Model
    Name    string `gorm:"size:100;not null"`
    Posts   []Post  // has many
    Profile Profile // has one
}

type Profile struct {
    gorm.Model
    UserID uint   // the foreign key, on the child as always
    Bio    string `gorm:"size:500"`
}
```

*What just happened:* `User.Profile` is a single `Profile` (not `[]Profile`), so GORM reads it as has-one.
The foreign key still lives on the *child* table - `Profile.UserID` - exactly like has-many. That's the
rule worth remembering: in both has-one and has-many, the FK sits on the "many"/owned side, pointing back
at the owner. The only thing that flips between them is whether the parent field is one struct or a slice.

## Many-to-many: Post ↔ Tag, through a join table

Tags break the pattern. A post can have many tags, and a tag can label many posts - so a single FK column
can't express it (where would you even put it?). This is what a **join table** is for: a separate little
table holding pairs of IDs, one row per "this post has this tag" fact.

GORM builds and manages that join table for you when you use the `many2many` tag:

```go
type Post struct {
    gorm.Model
    Title string `gorm:"size:200;not null"`
    Tags  []Tag  `gorm:"many2many:post_tags;"`
}

type Tag struct {
    gorm.Model
    Name  string `gorm:"size:50;uniqueIndex;not null"`
    Posts []Post `gorm:"many2many:post_tags;"` // optional: the other direction
}
```

*What just happened:* the `many2many:post_tags` tag tells GORM "the link between posts and tags lives in a
join table called `post_tags`." Neither struct gets a foreign-key field - there's nowhere to put it, which
is exactly why the join table exists. Putting the same tag on `Tag.Posts` lets you walk the relationship
from either side. AutoMigrate now creates a third table you never declared as a struct:

```sql
CREATE TABLE `post_tags` (
  `post_id` integer,
  `tag_id` integer,
  PRIMARY KEY (`post_id`,`tag_id`),
  CONSTRAINT `fk_post_tags_post` FOREIGN KEY (`post_id`) REFERENCES `posts`(`id`),
  CONSTRAINT `fk_post_tags_tag` FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`)
);
```

*What just happened:* `post_tags` is the generated join table - two FK columns, `post_id` and `tag_id`,
with a composite primary key so the same pair can't be inserted twice. Each row means "post X is tagged
with tag Y." GORM created and wired it from one struct tag; you never wrote a `PostTag` struct at all.

## Creating with nested associations

Now the payoff. Because GORM understands these relationships, you can create a parent and its children in
**one call** - GORM inserts everything and fills in the foreign keys for you. This is on by default (GORM
calls it full-save-associations).

```go
user := User{
    Name: "Ada",
    Posts: []Post{
        {Title: "Hello, world"},
        {Title: "On engines"},
    },
}
db.Create(&user)
```

*What just happened:* one `db.Create` inserted three rows: the user, plus both posts. Crucially, GORM read
the new `user.ID` after inserting the user, then stamped it into each post's `user_id` before inserting
them - so the children come out already wired to their parent. You didn't set a single `UserID` by hand.
This is the everyday way to seed connected data.

Let's complete the blog with a `Comment`. A comment is the textbook double-belongs-to: it belongs to the
post it's on **and** the user who wrote it - so it carries two foreign keys.

```go
type Comment struct {
    gorm.Model
    Body   string `gorm:"size:1000;not null"`
    PostID uint   // belongs to Post
    UserID uint   // belongs to User
}
```

*What just happened:* `Comment` has two FK columns, `PostID` and `UserID`, because it sits at the meeting
point of two relationships. Each one follows the same `<Type>ID` convention you've seen all phase. With
`User`, `Post`, `Comment`, and `Tag` all related, the blog schema is finally whole.

## Association mode: managing relations after the fact

Creating everything at once is great for fresh data, but often you need to attach or detach relations on a
record that already exists - add a tag to a published post, swap a post's whole tag set, clear them all.
That's **association mode**: `db.Model(&record).Association("FieldName")` gives you a little handle with
verbs for managing one relationship.

```go
var post Post
db.First(&post, 1) // load post #1

goLang := Tag{Name: "golang"}
db.Model(&post).Association("Tags").Append(&goLang)  // add one tag

db.Model(&post).Association("Tags").Replace(&Tag{Name: "orm"}) // set tags to exactly this

count := db.Model(&post).Association("Tags").Count() // how many tags now?

db.Model(&post).Association("Tags").Clear() // remove all tag links (tags themselves survive)
```

*What just happened:* each verb manages the **link**, not the tag rows themselves. `Append` adds a row to
`post_tags`. `Replace` swaps the post's entire set of tag links for the ones you pass. `Count` returns how
many are currently linked. `Clear` deletes the post's rows from the join table but leaves the `tags` table
untouched - you're cutting the connections, not deleting the tags. (`Delete` removes specific links you
name.) Use association mode whenever you're editing relationships on records that are already in the DB.

> 💡 Want the database to clean up automatically when a parent is deleted? Add a cascade tag:
> ``Posts []Post `gorm:"constraint:OnDelete:CASCADE;"` ``. Then deleting a user lets the DB delete that
> user's posts for you, enforced at the FK level.

## ⚠️ Declaring a relationship is not the same as loading it

Here's the trap that bites everyone exactly once. You define `User.Posts`, you migrate, the FK exists - 
and then you fetch a user and `user.Posts` is **empty**. Nothing's broken. GORM does not load associations
automatically when you read a record; it only loads the columns of the row itself.

```go
var user User
db.First(&user, 1)
fmt.Println(len(user.Posts)) // 0 - even though this user has posts!
```

*What just happened:* `db.First` ran one `SELECT` against the `users` table and filled in the user's own
fields. It did **not** go touch the `posts` table, so the `Posts` slice stays at its zero value: an empty
slice. The relationship is defined and real in the database - GORM just won't walk it unless you ask.

Asking is what **Preload** is for, and it's the entire subject of the next phase - along with the N+1
query explosion that ambushes people who try to load associations the naive way. For now, the takeaway is
the boundary: **defining an association sets up the wiring; loading it is a separate, deliberate step.**

## Recap

- An **association is a foreign key plus a Go field that mirrors it**. GORM reads your struct shape - a
  `<Type>ID` field next to a field of that type - and infers the relationship; you don't configure it
  separately.
- **Belongs-to / has-many** is one FK seen from two ends: the FK (`UserID`) and back-reference (`User`)
  live on the child; the parent holds a slice (`Posts []Post`). AutoMigrate builds the column and the FK
  constraint.
- **Has-one** is has-many with a single struct instead of a slice; the FK still sits on the child
  (`Profile.UserID`). **Many-to-many** uses a ``gorm:"many2many:post_tags;"`` tag, and AutoMigrate
  generates the join table for you.
- **`db.Create` with nested data** inserts parent and children in one call and fills in the foreign keys
  automatically. **Association mode** (`Append`/`Replace`/`Clear`/`Count`) manages links on records that
  already exist.
- **Defining a relationship does not load it.** Reading a record leaves its association slices empty until
  you Preload - that's [Phase 7](07-preloading-and-n-plus-1.md).

## Quick check

```quiz
[
  {
    "q": "In a belongs-to / has-many relationship between User and Post, where does the foreign-key column live?",
    "choices": ["On the users table, as post_id", "On the posts table, as user_id", "In a separate join table", "GORM stores it in memory only"],
    "answer": 1,
    "explain": "The FK sits on the child (the 'many' side). Post gets a user_id column pointing at users.id; the User.Posts slice is just the Go-side mirror of that link."
  },
  {
    "q": "You add `Tags []Tag `gorm:\"many2many:post_tags;\"`` to Post and run AutoMigrate. What does GORM create?",
    "choices": ["A tags_id column on the posts table", "Nothing until you also write a PostTag struct", "A post_tags join table with post_id and tag_id columns", "A JSON column holding the tag list"],
    "answer": 2,
    "explain": "Many-to-many can't be expressed with a single FK column, so GORM generates a join table (post_tags) holding pairs of post_id and tag_id - without you ever declaring it as a struct."
  },
  {
    "q": "You define User.Posts, then run db.First(&user, 1). Why is user.Posts empty even though the user has posts?",
    "choices": ["The migration failed silently", "Defining an association doesn't auto-load it - you must Preload", "First only ever returns one field", "The foreign key was never created"],
    "answer": 1,
    "explain": "GORM loads only the record's own columns, not its associations. The relationship is real in the DB; you have to ask for it explicitly with Preload (Phase 7)."
  }
]
```


---

# Preloading & the N+1 Trap

In [Associations](06-associations.md) you wired up the blog's relationships: a `User` has many `Post`s, a `Post` has many `Comment`s and belongs to a `User`. The schema knows about all of this, so you'd expect that when you load a user, their posts ride along for free.

They don't. The first time you hit this, you'll stare at an empty slice and wonder what you did wrong - so let's fix the mental model before we fix any code.

## The one fact that prevents 90% of the confusion

> 💡 **GORM does not lazy-load associations.** Loading a user does *not* load their posts. The related data shows up only when you explicitly ask for it with `Preload` (or `Joins`).

If you've come from Hibernate or some other ORM where touching `user.getPosts()` quietly fires a query in the background, throw that intuition away here. Go has no proxies, no magic getters, no hook that intercepts field access. A struct field is a struct field. When GORM hands you back a `User`, the `Posts` slice is whatever the constructor left it as - empty.

```go
var users []User
db.Find(&users)

fmt.Println(len(users))          // 3   ← the users loaded fine
fmt.Println(len(users[0].Posts)) // 0   ← but Posts is empty!
```

*What just happened:* `Find` ran exactly one query - `SELECT * FROM users` - and filled the slice. It never touched the `posts` table, so `users[0].Posts` is the zero value for a slice: empty. Nothing is wrong. GORM did precisely what you told it, which was "load users." You didn't ask for posts, so it didn't fetch them.

> 📝 This is a *feature*, not a missing one. Implicit loading is exactly what causes surprise queries and runaway latency in other ORMs. GORM makes you say what you want, so the query count is something you control on purpose.

## Preload: the two-query pattern

To get the posts, you ask:

```go
var users []User
db.Preload("Posts").Find(&users)

fmt.Println(len(users[0].Posts)) // 5   ← now they're here
```

*What just happened:* `Preload("Posts")` told GORM to also load each user's `Posts`. The string `"Posts"` is the **field name on the struct**, not a table name - match it to your Go field, capitalization and all. Now `users[0].Posts` is populated.

The interesting part is *how* GORM fills it. Watch the SQL it logs:

```sql
SELECT * FROM `users`;
SELECT * FROM `posts` WHERE `posts`.`user_id` IN (1,2,3);
```

*What just happened:* Two queries. The first loads all users. GORM collects their IDs - `1, 2, 3` - then fires **one** second query with `WHERE user_id IN (...)` to grab every post belonging to any of those users in a single round trip. Back in Go, it stitches each post onto its owner by matching `user_id`.

The number that matters: **this is two queries whether you have 3 users or 3,000.** The `IN` list grows, but the round-trip count does not. Hold onto that - it's the whole point of the next section.

## ⚠️ The N+1 trap

Here's the scene. You know `Find` doesn't load posts. So you reach for the "obvious" fix: loop over the users and load each one's posts as you go.

```go
var users []User
db.Find(&users)                 // 1 query

for i := range users {
    db.Where("user_id = ?", users[i].ID).
        Find(&users[i].Posts)   // 1 query - PER USER
}
```

*What just happened:* It works! Every user ends up with their posts. But count the queries: 1 to load the users, then 1 more for *each* user in the loop. With 3 users that's 4 queries. With 1,000 users it's **1,001 queries.** This is the **N+1 problem** - 1 query to get the parents, plus N queries (one per parent) to get the children.

Now the same job with `Preload`:

```go
var users []User
db.Preload("Posts").Find(&users) // 2 queries, always
```

*What just happened:* Identical result - every user has their posts - in exactly **2 queries** no matter how many users come back. The loop version scaled with your data; `Preload` doesn't.

Here's why it hurts so much in production. Each query is a network round trip to the database. On localhost a round trip is a fraction of a millisecond and you'll never notice the loop. Ship it to a real deployment where the database is 2ms away across the network, load a page listing 500 users, and you've just spent a full second doing nothing but waiting on round trips. The endpoint that flew in dev now crawls.

> ⚠️ The cruel part: N+1 is **invisible in testing**. Small datasets and a local database hide it completely. It only shows up under real load with real row counts - which is to say, in front of real users. The fix is to *look at your query log* in development, not your stopwatch.

This is the single most common ORM performance bug there is, and it's covered from the database's side in [Why Is My Query Slow?](/guides/why-is-my-query-slow). When you see "the page got slow after we added related data," N+1 is the first thing to check.

## Nested and conditioned preloads

Real pages need more than one level. The blog wants users, *their* posts, and *those* posts' comments. Chain the path with a dot:

```go
db.Preload("Posts.Comments").Find(&users)
```

*What just happened:* GORM walks the path two levels deep. It runs three queries - users, then posts for those users (`WHERE user_id IN (...)`), then comments for those posts (`WHERE post_id IN (...)`) - and stitches the whole tree together. Still a fixed, small number of queries, not one-per-row at any level.

Often you don't want *all* the children. Pass a condition as extra arguments and it becomes a `WHERE` on the preload query:

```go
db.Preload("Posts", "published = ?", true).Find(&users)
```

*What just happened:* The second query becomes `SELECT * FROM posts WHERE user_id IN (...) AND published = true`. Each user gets only their published posts; drafts never load. The condition syntax is the same `?`-placeholder style you used in [Querying](04-querying.md) - and the placeholder still protects you from SQL injection.

And when you genuinely want every direct association without naming each one:

```go
import "gorm.io/gorm/clause"

db.Preload(clause.Associations).Find(&users)
```

*What just happened:* `clause.Associations` is a shorthand that preloads **every association one level deep** - for the blog's `User`, that's `Posts` and any other direct relations. Note the limit: it goes one level only. It will *not* descend into `Posts.Comments`; nested paths you still spell out by hand.

## Joins vs Preload: one row vs many rows

`Preload` isn't the only way to pull in related data. `Joins` does it with an actual SQL `JOIN`:

```go
var posts []Post
db.Joins("User").Find(&posts)
```

*What just happened:* One query - `SELECT ... FROM posts LEFT JOIN users ON users.id = posts.user_id` - and each post comes back with its `User` field filled. A single round trip, no second query. For a **belongs-to** or **has-one** relationship, this is the better tool: each post has exactly one user, so the join adds one column-set per row and nothing multiplies.

So why not use `Joins` for everything? Because of what a JOIN does to **has-many**. Picture joining users to their posts:

```sql
SELECT * FROM users LEFT JOIN posts ON posts.user_id = users.id;
```

A user with 5 posts produces **5 rows** in the result - the user's columns repeated on every one. Ten users with 5 posts each is 50 rows, every user's data copied five times over the wire. That's the **row-multiplication** problem, and it gets worse the more children each parent has. `Preload`'s separate `IN` query sidesteps it entirely: users come back once, posts come back once, GORM assembles them in memory.

The rule of thumb:

- **Belongs-to / has-one (one related row)** → `Joins`. One query, no multiplication.
- **Has-many / many-to-many (many related rows)** → `Preload`. Avoids the row blow-up.

> 💡 This isn't a GORM quirk - it's the same tradeoff in every ORM. Hibernate calls it the same N+1 and offers `JOIN FETCH` versus batch loading ([Hibernate & JPA From Zero](/guides/hibernate-and-jpa-from-zero)); SQLAlchemy gives you `joinedload` versus `selectinload` ([SQLAlchemy From Zero](/guides/sqlalchemy-from-zero)) for exactly this one-row-vs-many-rows decision. Learn the shape once and it transfers to every data layer you'll ever touch.

## Recap

- **GORM never lazy-loads.** `db.Find(&users)` leaves `user.Posts` empty - there are no background queries on field access. You load associations explicitly or not at all.
- **`Preload` is the two-query pattern.** It loads the parents, then runs one `WHERE ... IN (...)` query for the children and stitches them together - 2 queries regardless of row count.
- **N+1 is the trap.** Looping and querying per parent is 1+N queries; `Preload` replaces it with 2. It's invisible on small/local data and brutal under real load - read your query log, not your stopwatch.
- **Nested and conditioned:** `Preload("Posts.Comments")` goes deep, `Preload("Posts", "published = ?", true)` filters children, `Preload(clause.Associations)` grabs everything one level down.
- **`Joins` vs `Preload`:** `Joins` (one SQL JOIN) for belongs-to/has-one; `Preload` (separate IN query) for has-many, because a JOIN multiplies parent rows by their children.
- The one-row-vs-many-rows decision is universal - the same trap and the same fix in Hibernate and SQLAlchemy.

## Quick check

```quiz
[
  {
    "q": "After db.Find(&users), what is in users[0].Posts?",
    "choices": ["The user's posts, loaded automatically", "An empty slice - GORM doesn't lazy-load", "A lazy proxy that loads on first access", "An error, because Posts wasn't selected"],
    "answer": 1,
    "explain": "GORM has no lazy loading. Find loads only users; Posts stays the empty zero value until you Preload it."
  },
  {
    "q": "You load 200 users, then loop and run db.Find(&u.Posts) per user. How many queries run?",
    "choices": ["2", "200", "201", "400"],
    "answer": 2,
    "explain": "1 query for the users plus N (200) for the loop = 201. That's the N+1 problem; Preload would make it 2."
  },
  {
    "q": "A Post belongs to one User. Which is the better tool to load each post's User?",
    "choices": ["Joins - one JOIN, no row multiplication", "Preload - to avoid the row blow-up", "A per-post query in a loop", "Neither; it loads automatically"],
    "answer": 0,
    "explain": "Belongs-to is one related row, so a JOIN adds no extra rows. Preload's separate query is for has-many, where a JOIN would multiply parent rows."
  }
]
```


---

# Transactions, Hooks & Migrations

By [Phase 7](07-preloading-and-n-plus-1.md) you can read your blog efficiently. Now 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](/guides/transactions-and-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.

```go
err := db.Transaction(func(tx *gorm.DB) error {
    user := User{Name: "Ada", Email: "ada@example.com"}
    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`.

```go
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.

```go
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](02-models-and-migration.md) 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](https://github.com/golang-migrate/migrate), [goose](https://github.com/pressly/goose),
or [atlas](https://atlasgo.io/).

The shape is the same across all of them: ordered pairs of SQL files, an **up** (apply the change) and
a **down** (undo it).

```sql
-- 000004_add_published_to_posts.up.sql
ALTER TABLE posts ADD COLUMN published BOOLEAN NOT NULL DEFAULT false;
```

```sql
-- 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`:

```bash
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](/guides/database-migrations).

> 📝 The practical 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 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.
- **`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

```quiz
[
  {
    "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."
  }
]
```


---

# GORM in the Real World & Where to Go Next

Stop for a second and look at the ground you've covered. You can describe a table as a Go struct and let `AutoMigrate` build it. You can `Create` and read records back. You can chain `Where`, `Order`, `Limit`, and `Select` into the exact query you want, and wrap reusable bits into scopes. You know the zero-value update trap and how to dodge it. You can model has-one, has-many, belongs-to, and many-to-many relationships, and you can spot the N+1 explosion before it ships and reach for `Preload` or `Joins`. You can wrap a sequence of writes in a transaction, hang behavior off lifecycle hooks, and you know why `AutoMigrate` is a starting point rather than a production migration story.

Most of all - and this is the whole point of learning GORM the way we did - you can read the SQL underneath. With the logger on, GORM stopped being a magic box and became a SQL generator whose output you can predict and debug. That's the skill that outlives any one library.

This last phase isn't new mechanics - it's about where GORM actually lives in real codebases, where it isn't the right tool, and what to build to make all of this stick.

## When to drop to raw SQL

Here's a thing worth saying out loud, because it surprises people who expect an ORM to be a cage: **GORM never traps you.** Any time the generated SQL gets awkward, you can go SQL-first for that one query and keep using GORM for everything else.

When does that moment come?

- **Complex reporting queries** - a seven-way join, window functions, a recursive CTE. The ORM fights you here, and you shouldn't fight back.
- **Database-specific features** - something only your engine offers, that GORM's portable layer doesn't expose cleanly.
- **Performance-critical paths** - a hot query where GORM's generated SQL is suboptimal and you want to hand-tune every clause.

Two methods cover it. Use `Raw` for reads and scan into a struct or slice:

```go
type Report struct {
    AuthorID uint
    PostCount int
}

var rows []Report
db.Raw(`
    SELECT author_id, COUNT(*) AS post_count
    FROM posts
    GROUP BY author_id
    HAVING COUNT(*) > ?
`, 5).Scan(&rows)
```

And `Exec` for writes that don't return rows:

```go
db.Exec("UPDATE posts SET published = ? WHERE author_id = ?", true, authorID)
```

📝 Notice the `?` placeholders with arguments passed separately - that's GORM parameterizing the query for you, the same protection against SQL injection you get from the rest of the API. Don't build SQL by gluing strings together with user input. Drop to raw SQL, yes; drop your guard, no.

## GORM vs sqlc, sqlx, and ent

GORM is the popular default in Go, but it isn't the only way to talk to a database - and Go culture in particular has a strong SQL-first streak. Knowing the landscape helps you pick well and read other people's code.

- **GORM** - a full ORM. You describe data as structs and chain methods; it writes the SQL. Optimizes for convention and productivity.
- **sqlc** - SQL-first with no runtime ORM. *You* write the SQL in `.sql` files, and sqlc generates type-safe Go functions from it. Your queries are exactly what you wrote, checked at build time.
- **sqlx** - thin helpers over the standard `database/sql`. You write the SQL; sqlx scans the results into your structs so you skip the tedious `rows.Scan(&a, &b, &c)` boilerplate.
- **ent** - a schema-as-code graph ORM from Meta, strong on traversing relationships as a graph.
- **bun** - another SQL-leaning query builder/ORM, lighter than GORM.

```mermaid
flowchart TD
  A[Need a data layer] --> B{Want to write the SQL yourself?}
  B -- No, give me convention --> C[GORM]
  B -- Yes, SQL-first --> D{Want generated type-safe code?}
  D -- Yes --> E[sqlc]
  D -- No, just scan rows --> F[sqlx]
```

💡 The practical rule: reach for **GORM when you want productivity and convention** - fast CRUD, relationships handled, migrations baked in. Reach for **sqlc or sqlx when you want SQL-first control and predictable queries** - you'd rather own the exact SQL than have it generated for you. None of these is "better." They're different bets about who writes the SQL, and you can now make that call with your eyes open.

## The caveats, plainly - and production tips

A battle-hardened friend tells you where the dragons are. Here's the short list of GORM's, all of which you've already met:

- **The zero-value update trap (Phase 5).** `Updates` with a struct skips fields holding Go zero values (`0`, `""`, `false`), so a "set published to false" silently does nothing. Use a `map[string]interface{}` or `Select` the columns when you mean to write a zero.
- **Forgetting `Preload` → N+1 (Phase 7).** Loading a list and then touching each item's association fires one query per row. Eager-load with `Preload` or `Joins` and watch the count drop.
- **Generated SQL can be suboptimal.** GORM aims for portability, not always the leanest query. Keep the logger on and read what it emits. When a query is slow, the logged SQL is your first clue - see [Why Is My Query Slow?](/guides/why-is-my-query-slow).

And two production habits that matter the moment real traffic shows up:

**Tune the connection pool.** GORM sits on top of `database/sql`, which pools connections. The defaults are fine for a demo and wrong for production:

```go
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(25)
sqlDB.SetConnMaxLifetime(5 * time.Minute)
```

**Pass request context.** Wire the incoming request's `context.Context` into your queries so they cancel when the client goes away or the deadline passes - no orphaned queries hammering the database after the user has left:

```go
db.WithContext(ctx).Where("author_id = ?", id).Find(&posts)
```

📝 These two - a sized pool and `WithContext` - are most of what separates a tutorial GORM app from one that survives a busy afternoon.

## What to build

Reading got you here. Building is what makes it last. You already have the perfect sandbox: the **blog** schema this guide grew - users, posts, comments, and tags, with every relationship shape and the N+1 trap baked right in.

Take it further than a script. Put a real web framework on top and serve it as an API:

- **[Gin](/guides/gin-from-zero)** - the most popular Go web framework, fast and minimal.
- **[Echo](/guides/echo-from-zero)** - batteries-included with a clean API.
- **[chi](/guides/chi-from-zero)** - idiomatic routing built on the standard library.

Pick one, expose a few endpoints (`POST /posts`, `GET /posts/:id` with its comments preloaded, `GET /authors/:id/posts`), keep the GORM logger on, and watch the SQL scroll past as requests come in. Then deploy it somewhere - even a tiny instance. Seeing your structs become tables, your handlers become queries, and your queries become logged SQL in production is the most satisfying exercise in this whole guide.

Whichever framework you pick, **finish one.** A small app you actually debugged and deployed teaches more than three half-built ones.

You came in seeing an ORM as a trick that turned objects into rows somehow. You're leaving able to model, migrate, query, relate, beat N+1, transact, and - when the ORM gets in your way - drop to the SQL it was writing for you all along. A **struct is a table**, a **`*gorm.DB` is a query you chain**, and you can always see (and reach past) the SQL underneath. Go build the small thing.

## Recap

1. **GORM never locks you in.** Drop to raw SQL with `db.Raw(...).Scan(&out)` for reads and `db.Exec(...)` for writes when reporting, DB-specific features, or hot paths make GORM's SQL awkward - keep using `?` placeholders so it stays parameterized.
2. **Know the alternatives.** GORM is the full-ORM, convention-and-productivity choice; **sqlc** (generated type-safe Go from your SQL) and **sqlx** (thin scanning helpers) are the SQL-first picks; ent and bun round out the field. Pick by who you want writing the SQL.
3. **Remember the caveats.** The zero-value update trap, forgetting `Preload` and triggering N+1, and occasionally suboptimal generated SQL - all manageable once you keep the logger on and read what GORM emits.
4. **Production needs two habits.** Size the pool via `sqlDB, _ := db.DB()` and `SetMaxOpenConns`/`SetMaxIdleConns`/`SetConnMaxLifetime`, and pass `db.WithContext(ctx)` so queries cancel with the request.
5. **Build the blog for real:** users/posts/comments/tags behind a Gin, Echo, or chi API, deployed, with the SQL logger on. Finish one.

## Quick check

One last check - on how GORM shows up in real Go services:

```quiz
[
  {
    "q": "You need a gnarly reporting query - a multi-table join with window functions - and GORM's generated SQL is awkward. What's the mature move?",
    "choices": [
      "Drop to raw SQL with db.Raw(...).Scan(&out) for that one query and keep using GORM everywhere else",
      "Abandon GORM entirely and rewrite the whole app on database/sql",
      "Force it through Preload no matter how many queries it fires",
      "Build the SQL string by concatenating the user's input directly"
    ],
    "answer": 0,
    "explain": "GORM never traps you. Use db.Raw(...).Scan() (or db.Exec() for writes) for the awkward query and keep the ORM for the rest - and keep ? placeholders so it stays parameterized against injection."
  },
  {
    "q": "Your team wants to write the SQL by hand and get type-safe Go generated from it, with no runtime ORM. Which tool fits?",
    "choices": [
      "sqlc - SQL-first, it generates type-safe Go functions from the SQL you write",
      "GORM - it writes the SQL for you from struct method chains",
      "ent - a schema-as-code graph ORM",
      "AutoMigrate - that's a schema tool, not a query layer"
    ],
    "answer": 0,
    "explain": "sqlc is the SQL-first, code-generation choice: you write the .sql, it generates type-safe Go. GORM is the convention/productivity ORM that writes SQL for you; sqlx is thin scanning helpers; ent is a graph ORM."
  },
  {
    "q": "What are the two production habits that most separate a tutorial GORM app from one that survives real traffic?",
    "choices": [
      "Tune the connection pool via sqlDB, _ := db.DB() with SetMaxOpenConns/SetMaxIdleConns/SetConnMaxLifetime, and pass db.WithContext(ctx) so queries cancel with the request",
      "Turn off the SQL logger and add more Preloads everywhere",
      "Replace every query with db.Raw and never use the ORM",
      "Run AutoMigrate on every request to keep the schema fresh"
    ],
    "answer": 0,
    "explain": "GORM sits on database/sql, so size the pool (max open/idle conns and conn lifetime). And wire the request context with db.WithContext(ctx) so queries cancel when the client or deadline goes away - no orphaned queries hammering the DB."
  }
]
```
