Session 06 ยท โœ๏ธ CRUD

CRUD & Migrations

Four verbs and a schema that has to change while the application is running. GORM makes all five easy โ€” including the two that are easy to get catastrophically wrong.

โœ“
6acreate, read, update, delete
โœ“
6bSave vs Update vs Updates
โœ“
6cAutoMigrate vs real migrations

6a

The four verbs, and the SQL each one really runs

Every GORM call becomes one statement. Walk it with Next โ€” watch the rows change, and watch two calls do something you did not ask for.

Cdb.Create(&a) inserts and writes the new id back into the struct โ€” PostgreSQL via RETURNING. After the call, a.ID is populated.
RFirst returns ErrRecordNotFound when nothing matches; Find into a slice returns no error at all for an empty result. Two different contracts, and mixing them up is a real bug.
USave writes every field. Updates with a struct writes only non-zero fields. Updates with a map writes exactly what you list โ€” which is the only one of the three that is unambiguous.
DDelete needs a primary key or a Where. Without either, GORM refuses โ€” a deliberate guard against deleting the whole table.

Every one of these returns a *gorm.DB, not an error. The error is in .Error, and the rows affected are in .RowsAffected. Ignoring the second is how an update that matched nothing gets reported as a success.

6b

The zero-value trap

This is the single most common GORM bug in production, and it is entirely silent.

three ways to say "update", three different statements
// 1. struct โ€” non-zero fields ONLY. duration_min is 0, so it is SKIPPED.
db.Model(&a).Updates(Appointment{Status: "cancelled", DurationMin: 0})
-- UPDATE appointments SET status='cancelled' WHERE id = 42
-- the duration you explicitly set to 0 was silently dropped

// 2. map โ€” exactly what you listed, zero values included.
db.Model(&a).Updates(map[string]any{"status": "cancelled", "duration_min": 0})
-- UPDATE appointments SET status='cancelled', duration_min=0 WHERE id = 42

// 3. Save โ€” EVERY field, including the ones you never touched.
db.Save(&a)
-- UPDATE appointments SET patient_id=.., doctor_id=.., starts_at=..,
--        duration_min=.., status=.., fee=.., created_at=.. WHERE id = 42
-- if `a` was loaded before someone else's write, you just reverted it
โœ—Struct updates cannot express "set this to zero". false, 0 and "" are indistinguishable from "not provided", so GORM omits them. Cancelling a fee by setting it to 0 does nothing at all.
โœ“Use a map when any field can legitimately be zero. It is more verbose and completely unambiguous โ€” the right trade in a repository.
โœ—Save is a last-write-wins overwrite. Load a row, spend 200ms on business logic, call Save, and you have just written back stale values for every field another request changed meanwhile.
โ†’Check RowsAffected on every update. Zero rows means the WHERE matched nothing โ€” usually a wrong id, sometimes a row someone already deleted. It is not an error, so nothing will tell you.
Tip

Prefer Select with an explicit field list when you do use a struct: db.Model(&a).Select("status","duration_min").Updates(a). Now the zero values are included and the set of writable columns is stated at the call site, where a reviewer can see it.

6c

AutoMigrate is a development tool

It reads your structs and makes the database look more like them. Note what it will not do.

Change
AutoMigrate
Versioned migration
Add a column
yes
yes
Rename a column
creates a second one, keeps the old
explicit RENAME, data intact
Drop a column
never โ€” the column stays forever
yes, deliberately
Change a type
sometimes, without a USING clause
yes, with the cast you wrote
Backfill data
no concept of it
it is just SQL
Roll back
no
the down migration
migrations/000004_add_cancelled_at.up.sql โ€” what production uses
ALTER TABLE appointments ADD COLUMN cancelled_at timestamptz;

-- backfill before the constraint, never after
UPDATE appointments SET cancelled_at = updated_at
 WHERE status = 'cancelled' AND cancelled_at IS NULL;

CREATE INDEX CONCURRENTLY idx_appointments_cancelled
    ON appointments (cancelled_at) WHERE cancelled_at IS NOT NULL;
-- CONCURRENTLY: does not lock writes on a live table
โœ“AutoMigrate is genuinely good for local development and for tests that spin up a throwaway database. Use it there without guilt.
โœ—A rename produces both columns. phone โ†’ phone_number leaves the old column populated and the new one empty, and no error is raised. Your reads now return empty strings.
โ†’Migrations are files in version control, applied in order, each with an up and a down. golang-migrate and goose are the two common choices; both are a single binary plus a folder of SQL.
โ†’Run migrations as a separate step, not from application startup. Three replicas booting at once should not race to alter the same table.
TASK

Break it, then fix it properly

(a) Load an appointment, set DurationMin to 0 via a struct Updates, and confirm from the SQL log that the column was not written. Then make it work two different ways. (b) Rename phone to phone_number in your struct and run AutoMigrate against a copy of the database โ€” then show what \d patients reports. (c) Write the up and down migration that performs the rename correctly, preserving the data.

Recap

Session 06 in four lines

1Create writes the id back; First errors on no rows but Find does not; Delete refuses to run without a key or Where.
2Struct updates silently skip zero values โ€” use a map, or Select the columns explicitly.
3Save writes every field and will happily revert another request's changes; check RowsAffected on every update.
4AutoMigrate only ever adds. Renames leave both columns and drops never happen โ€” production needs versioned migration files.