Updated Jun 23, 2026

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. The good news: 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:

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

db.Model(&user).Update("name", "Bob")
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:

db.Model(&user).Updates(User{Name: "Bob", Age: 0})
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:

db.Model(&user).Updates(map[string]any{"name": "Bob", "age": 0})
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:

user.Name = "Bob"
user.Age = 0
db.Save(&user)
UPDATE `users` SET `name`='Bob',`age`=0,`email`='[email protected]',`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:

db.Model(&User{}).Where("age < ?", 18).Update("active", false)
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:

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:

var user User
db.First(&user, 1)
db.Delete(&user)
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 Finds and Firsts too:

var users []User
db.Find(&users) // the soft-deleted user 1 is NOT in here
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:

var users []User
db.Unscoped().Find(&users) // includes soft-deleted rows
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:

db.Unscoped().Delete(&user)
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

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

← Phase 4: Querying · Guide overview · Phase 6: Associations →

Check your understanding

1. When does db.Model(&user).Updates(...) write a zero value like age = 0 to the database?

2. Why does db.Model(&User{}).Update("active", false) (no Where) fail by default?

3. After db.Delete(&user) on a gorm.Model-backed type, how do you see that row again in a query?

Was this page helpful?