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.
6a
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.
db.Create(&a) inserts and writes the new id back into the struct โ PostgreSQL via RETURNING. After the call, a.ID is populated.First 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.Save 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.Delete 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
This is the single most common GORM bug in production, and it is entirely silent.
// 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
false, 0 and "" are indistinguishable from "not provided", so GORM omits them. Cancelling a fee by setting it to 0 does nothing at all.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.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.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
It reads your structs and makes the database look more like them. Note what it will not do.
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.phone โ phone_number leaves the old column populated and the new one empty, and no error is raised. Your reads now return empty strings.golang-migrate and goose are the two common choices; both are a single binary plus a folder of SQL.(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
Create writes the id back; First errors on no rows but Find does not; Delete refuses to run without a key or Where.Select the columns explicitly.Save writes every field and will happily revert another request's changes; check RowsAffected on every update.AutoMigrate only ever adds. Renames leave both columns and drops never happen โ production needs versioned migration files.