---
title: "Обновление записей — Update, Updates, UpdateColumn, gorm.Expr и блокировки"
description: "Обновление записей — gorm update: Update и Updates структурой и картой, UpdateColumn без хуков, gorm.Expr, RowsAffected, ErrMissingWhereClause и FOR UPDATE."
course: "GORM: ORM-библиотека для Go"
url: "https://golangmaster.ru/courses/gorm/lessons/08-update/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Обновление записей — Update, Updates, UpdateColumn, gorm.Expr и блокировки

*Курс «GORM: ORM-библиотека для Go», урок 8 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/gorm/lessons/08-update/*

Чтение и фильтрация из двух предыдущих уроков закрыли половину CRUD — настала очередь обновлений. gorm update — это три метода разного калибра: Update для одного поля, Updates для пачки колонок, UpdateColumn для служебных правок без хуков, плюс gorm.Expr — способ отдать базе SQL-выражение вместо значения. Обновление — самая коварная операция из четырёх: INSERT при ошибке ругается сразу, а UPDATE с широким условием молча перепишет половину таблицы, и узнают об этом пользователи. Поэтому в уроке не только синтаксис, но и защита: ErrMissingWhereClause против глобальных правок, RowsAffected как ответ на вопрос «а строка-то нашлась?», и две схемы борьбы за одну строку — оптимистичная блокировка с версией и пессимистичная SELECT FOR UPDATE. Рабочие данные — те же пять задач в todo_items в состоянии из седьмого урока: «Купить хлеб» и «Обновить бюджет» закрыты, «Полить цветы» и «Заплатить за интернет» ждут, «Ответить заказчику» в работе, приоритеты 0, 0, 2, 3, 1. Одна честная оговорка о формате: СУБД в браузерной песочнице не живёт, поэтому runner-ов в курсе нет — вас проверяют quiz, predict, fill, order и карточки, а примеры рассчитаны на локальный Go с PostgreSQL или SQLite. Каждый пример снабжён SQL-следом — его печатает логгер из второго урока.

### gorm update: Update — одно поле, WHERE и след

Сигнатура из finisher_api.go лаконична: func (db *DB) Update(column string, value interface{}) (tx *DB). Внутри метод кладёт в Statement.Dest карту map[string]interface{}{column: value} и запускает цепочку колбэков Update — отсюда все поведения, о которых пойдёт речь: WHERE из модели, автоправка updated_at, защита от глобальных правок. WHERE берётся из двух мест, и полезно понимать, какое сработало. Первое — первичный ключ структуры в Model(&task): после чтения из базы task.ID = 3, и при обновлении GORM сам превратит ключ в условие WHERE id = 3 — механика ConvertToAssignments в callbacks/update.go выносит первичные ключи обновляемой модели из SET в WHERE. Второе — явный Where: db.Model(&TodoItem{}).Where("status = ?", "todo").Update(...) — формы условий ровно те, что разобраны седьмым уроком, ничего нового придумывать не нужно. Если модель в Model без ключа и Where нет — сработает защита ErrMissingWhereClause, ей посвящён отдельный раздел ниже. Кто ищет update gorm в поиске, хочет именно эту пару: Update по колонке плюс условие из Where. Одна колонка за вызов — как только полей становится два, дорога ведёт к Updates, следующему разделу.

### Update и его SQL-след

```go
// Читаем задачу 3 «Ответить заказчику», она в статусе doing
var task TodoItem
db.First(&task, 3)

// WHERE из первичного ключа: GORM сам превратил task.ID в условие
db.Model(&task).Update("status", "done")
// UPDATE "todo_items" SET "status"='done',"updated_at"='...' WHERE id = 3

// WHERE явный: формы условий - те же, что разобраны седьмым уроком
db.Model(&TodoItem{}).Where("status = ?", "todo").Update("priority", 2)
// UPDATE "todo_items" SET "priority"=2,"updated_at"='...' WHERE status = 'todo'

// Батч-обновление по списку ключей
db.Model(&TodoItem{}).Where("id IN ?", []uint{2, 4}).Update("priority", 3)
// UPDATE "todo_items" SET "priority"=3,"updated_at"='...' WHERE id IN (2,4)
```

Первый вызов — самый частый в живых сервисах: модель прочитана из базы, вы правите одно поле, ключ из структуры уезжает в WHERE автоматически, руками писать Where("id = ?", task.ID) не нужно. Во втором и третьем вызовах Model(&TodoItem{}) — модель без ключа: она нужна, чтобы GORM знал таблицу и схему, а условие приходит целиком из Where. Во всех трёх следах видно подмену updated_at — её механика ниже в уроке.

### Проверка: откуда берётся WHERE

**Вопрос:** task прочитан из базы и имеет ID = 3. Что сделает db.Model(&task).Update("status", "done")?

- ✅ Обновит одну строку: GORM вынесет первичный ключ модели в условие WHERE id = 3
- Вернёт ErrMissingWhereClause: явный Where обязателен всегда
- Обновит всю таблицу: условие из Model не собирается
- Паника: Update нельзя сочетать с Model

Первичные ключи обновляемой модели GORM выносит из SET в WHERE — механика ConvertToAssignments в callbacks/update.go. Условия нет только когда ключа в модели нет и Where не написан: тогда включается защита ErrMissingWhereClause. Паники нет: Update работает только в связке с Model — без модели он даже не узнает таблицу.

### Updates: структура против карты

Сигнатура соседа: func (db *DB) Updates(values interface{}) (tx *DB), а комментарий в finisher_api.go уточняет: values must be a struct or map — третьего не дано. Структура — продолжение знакомой истории: обновление собирается только из ненулевых полей, то же isZero-правило, что и в структурных условиях седьмого урока, работает и здесь. Задали Done: false — поле молча выпало из SET. Не ошибка, а самая дорогая тишина в CRUD: запрос выглядит успешным, значение не тронуто, заметите вы это по косвенным признакам. Зато каждая ненулевая колонка типизирована компилятором: опечатка в имени поля ловится на этапе сборки, а не в продакшене. Карта — противоположный характер: едут все ключи, включая нули, потому что карта не умеет отличать «пусто» от «не заполнял»; это путь к честным done = false и priority = 0. Две детали механики из callbacks/update.go. Первая: ключи карты GORM сортирует — sort.Strings над списком ключей, поэтому колонки в SET идут по алфавиту и SQL-след стабилен от запуска к запуску. Вторая: updated_at дописывается сам, если его нет среди ключей карты или полей структуры — разговор о времени ниже, в разделе про UpdateColumn. Как выбирать: типобезопасные частичные правки — структурой; нулевые значения и динамический состав из запросов API — картой.

### Updates: структура, карта, Select, Omit

```go
// Структура: только ненулевые поля; Done: false в SET не поедет
db.Model(&task).Updates(TodoItem{Status: "done", Priority: 5})
// UPDATE ... SET "priority"=5,"status"='done',"updated_at"='...' WHERE id = 3

// Карта: нули уважаются, ключи в SET идут по алфавиту
db.Model(&task).Updates(map[string]interface{}{"done": false, "priority": 0})
// UPDATE ... SET "done"=false,"priority"=0,"updated_at"='...' WHERE id = 3

// Select форсирует перечисленное поле структуры, даже нулевое
db.Model(&task).Select("Done").Updates(TodoItem{Done: false})
// UPDATE ... SET "done"=false,"updated_at"='...' WHERE id = 3

// Omit вычёркивает колонку из обновления
db.Model(&task).Omit("priority").Updates(TodoItem{Status: "done", Priority: 5})
// UPDATE ... SET "status"='done',"updated_at"='...' - priority не тронут
```

Сравните первый и второй следы: одна и та же мысль «закрыть задачу и обнулить приоритет» структурой получилась только наполовину. Select("Done") — лечение в структурной форме: перечисленное поле едет даже нулевым; Select("*") возьмёт всю структуру целиком, включая нули. Omit работает и со структурой, и с картой. Всё это — тот же белый список с форсом нулей, что и второй аргумент Where из седьмого урока, только применённый к SET.

### Что уедет в SET?

```go
// Задача 3 «Ответить заказчику»: в базе done = false, priority = 2
var task TodoItem
db.First(&task, 3)

result := db.Model(&task).Updates(TodoItem{
        Done:     false,
        Priority: 5,
        Status:   "done",
})
fmt.Println(result.RowsAffected, result.Error)
```

**Что выведет программа?**

- ✅ 1 <nil>: SET priority = 5, status = 'done', updated_at = ... — false это zero value, поле Done молча выпало
- 1 <nil>: SET done = false, priority = 5, status = 'done', updated_at = ... — структура едет вся
- 0 record not found: Updates не нашёл задачу по ключу
- 1 <nil>: SET done = false — GORM обновит только изменившееся поле

Структурное обновление собирается из ненулевых полей: Priority: 5 и Status: "done" едут в SET, Done: false — zero value, пропускается без предупреждения. Честный false пишут картой map[string]interface{}{"done": false} или через Select("Done"). RowsAffected при этом честный: строка нашлась по ключу и обновлена. И заметьте: GORM не строит дифф с текущими значениями базы — «обновит только изменившееся» он не умеет, SET собирается из структуры, а не из сравнения.

### Select и Omit: состав SET под контролем

Оба метода в обновлении — про белый список колонок, и это зеркальное отражение Specify Struct search fields из седьмого урока: только вместо WHERE фильтруется SET. Select перечисляет, что ехать: перечисленное включается даже нулевым — единственный структурный способ записать ноль без карты. Select("*") берёт всю структуру как есть, с нулями, — способ «передать модель целиком». Omit вычёркивает колонку — тоже и со структурой, и с картой: так обновляют всё, кроме опасной колонки, скажем slug или version, случайная перезапись которых ломает данные. У приёма та же обратная сторона, что у условий: рестрикт. Забытая в Select колонка молча не поедет — запрос завершится успехом, а поле останется старым, и никакая ошибка об этом не скажет. Отсюда дисциплина: состав SET критичен — перечисляйте Select явно и смотрите SQL-след перед отправкой; пусть лучше лишнее уедет осознанно, чем нужное пропадёт молча.

### Проверка: Select с нулевым полем

**Вопрос:** Что запишет в базу db.Model(&task).Select("Done").Updates(TodoItem{Done: false})?

- Пустой SET и ошибку: обновлять нечего, false отброшен
- ✅ SET done = false, updated_at = ...: Select форсирует нулевое поле, а время дописывается само
- SET updated_at = ...: false отфильтрован как zero value, запись только времени
- Ничего: Updates без Where вернёт ErrMissingWhereClause

Select в обновлении снимает isZero-фильтр: перечисленное поле едет в SET любым значением, даже нулевым. updated_at при этом дописывается автоматически — механика AutoUpdateTime живёт поверх Select, пока вы не перешли на UpdateColumn. ErrMissingWhereClause не при чём: у модели с ключом условие WHERE id = 3 собирается само.

### RowsAffected: сколько строк реально тронуто

У результата любого финишера есть result.RowsAffected, и для обновлений это главный ответ на вопрос «что произошло». UPDATE без совпадений — законная ситуация: задачу удалил другой пользователь, ключ остался от старого кеша, фильтр не совпал. GORM вернёт RowsAffected = 0 и Error = nil: не нашлось — не ошибка, в отличие от First с его ErrRecordNotFound из шестого урока. Отсюда две привычки. Первая: в API, который обещает клиенту конкретную запись, проверяйте «нашлась ли строка» — иначе пользователь получит успешный 200 OK при несуществующем id. Вторая: знать, что именно считает база. PostgreSQL возвращает все строки, затронутые UPDATE, даже если значения не изменились. MySQL по умолчанию считает только реально изменённые: SET priority = 2 на строке, где уже priority = 2, даст ноль. Драйвер go-sql-driver имеет параметр DSN clientFoundRows=true — он возвращает MySQL к поведению «найденных» строк. SQLite считает затронутые строки, как PostgreSQL: проверка запросом с неизменяющим SET это покажет за минуту. Для атомарных инкрементов и блокировок ниже RowsAffected == 0 станет точным признаком проигранной гонки.

### Сколько строк обновит батч?

```go
// Рабочее состояние: задачи 2 и 4 в статусе todo
result := db.Model(&TodoItem{}).
        Where("status = ?", "todo").
        Update("status", "doing")

fmt.Println(result.RowsAffected, result.Error)
```

**Что выведет программа?**

- ✅ 2 <nil>: обе задачи переехали из todo в doing, RowsAffected считает затронутые строки
- 0 record not found: батч-обновление без ключа в Model возвращает not found
- 5 <nil>: RowsAffected вернул общее число строк таблицы
- 0 <nil>: Update по полю, равному значению условия, ничего не делает

Батч-обновление без ключа в Model идёт по явному WHERE: обе строки в статусе todo затронуты — RowsAffected = 2, ошибки нет. Значения у обеих реально менялись, поэтому число одинаково и в PostgreSQL, и в MySQL с его «считать изменённые»: расхождение диалектов проявляется, только когда часть строк уже имела целевое значение. ErrRecordNotFound — территория First из шестого урока, обновлениям она не знакома.

### UpdateColumn и UpdateColumns: SkipHooks из finisher_api.go

В finisher_api.go рядом с Update живут два метода, отличающиеся одной строкой: tx.Statement.SkipHooks = true. UpdateColumn(column, value) — для одного поля, UpdateColumns(values) — для пачки, структура или карта. SkipHooks выключает сразу два слоя автоматики. Первый — хуки модели: BeforeSave и BeforeUpdate перед запросом, AfterSave и AfterUpdate после; служебная правка не должна дёргать бизнес-логику, которую вы повесили на модель. Второй — автоправка времени. У полей с меткой AutoUpdateTime (ваш UpdatedAt из схемы — time.Time поле размечается автоматически) каждый Update и Updates подменяют значение текущим временем: при структурном обновлении поле всегда пишется now, при обновлении картой — если ключа updated_at нет среди прочих; картой время можно и перезаписать явно, положив своё значение в ключ. UpdateColumn и UpdateColumns этого не делают: updated_at остаётся каким был. Смысл прозрачен: updated_at должен означать «пользователь тронул запись», а не «скрипт пересчитал счётчик». Учтите: защита от глобальных обновлений на них распространяется в полном объёме — WHERE нужен и здесь.

### Update против UpdateColumn: SQL-след

```go
// Update: колонка + автоправка updated_at, вокруг запроса - хуки
db.Model(&task).Update("priority", 2)
// UPDATE "todo_items" SET "priority"=2,"updated_at"='...' WHERE id = 3

// UpdateColumn: ровно то, что попросили - без времени и без хуков
db.Model(&task).UpdateColumn("priority", 2)
// UPDATE "todo_items" SET "priority"=2 WHERE id = 3

// UpdateColumns: то же для пачки, структура или карта
db.Model(&task).UpdateColumns(map[string]interface{}{"priority": 2})
// UPDATE "todo_items" SET "priority"=2 WHERE id = 3

// Выражения работают и здесь: декремент без гонки и без updated_at
db.Model(&task).Where("priority > ?", 0).
        UpdateColumn("priority", gorm.Expr("priority - ?", 1))
// UPDATE "todo_items" SET "priority"=priority-1 WHERE id = 3 AND priority > 0
```

Следы отличаются одним слагаемым — и этим всё сказано: UpdateColumn правит то, что вы просили, и ничего больше. Классическое применение — счётчик просмотров или остаток на складе: тысячи обновлений в минуту, у каждого своя семантика времени, и updated_at от них шалеть не должен. Заметьте: WHERE требуется и здесь — checkMissingWhereConditions охраняет всю четвёрку методов.

> **Глобальный апдейт не пройдёт.** Обновление без условий GORM не выполнит: db.Model(&TodoItem{}).Update("status", "done") вернёт ErrMissingWhereClause — в errors.go его текст ровно такой: «WHERE conditions required». Проверка живёт в callbacks/helper.go (checkMissingWhereConditions) и выполняется до отправки запроса: Error не nil, RowsAffected = 0, база не тронута — случайно закрыть все задачи одной строкой не получится. Это защита от самой дорогой опечатки CRUD: пропущенный Where — и пол-таблицы с новым значением. Легальных выхода три, и все осознанные: тривиальное условие Where("1 = 1"), сырой SQL через db.Exec (строка Exec не экранируется — ввод пользователя только аргументами, гигиена седьмого урока) или режим AllowGlobalUpdate на сессию. Нюанс на полях: если модель несёт gorm.DeletedAt, к каждому UPDATE дописывается deleted_at IS NULL — но сам по себе он условием не считается, детали девятого урока.

### Три осознанных выхода из запрета

```go
// Запрещено: условий нет
result := db.Model(&TodoItem{}).Update("status", "done")
fmt.Println(result.Error, result.RowsAffected)
// WHERE conditions required 0 - запрос в базу даже не ушёл

// Выход 1: тривиальное условие - подпись под массовой операцией
db.Model(&TodoItem{}).Where("1 = 1").Update("status", "done")
// UPDATE "todo_items" SET "status"='done',"updated_at"='...' WHERE 1=1

// Выход 2: сырой SQL - для скриптов и сложных выражений
db.Exec("UPDATE todo_items SET status = 'done'")
// UPDATE todo_items SET status = 'done'

// Выход 3: AllowGlobalUpdate на сессию
db.Session(&gorm.Session{AllowGlobalUpdate: true}).
        Model(&TodoItem{}).Update("status", "done")
// UPDATE "todo_items" SET "status"='done',"updated_at"='...'
```

Все три пути легальны, но читаются по-разному. Where("1 = 1") — честная подпись под массовой операцией: её видно и в коде, и в следе. db.Exec — территория сырого SQL: аргументы подставляйте плейсхолдерами, ввод пользователя в строку не вклеивайте; затронутые строки считаются через result.RowsAffected и здесь. AllowGlobalUpdate можно задать и в конфиге подключения, и на конкретную сессию — второй способ лучше: опасный участок локализован одной цепочкой, и поиск по кодовой базе его находит.

### gorm.Expr: атомарные инкременты без гонки

Классический баг конкурентности: два воркера поднимают приоритет одной задачи. Оба читают priority = 2, оба прибавляют единицу в Go, оба пишут 3 — одно повышение потеряно. Схема «прочитал — посчитал — записал» ломается всегда, когда между чтением и записью может вклиниться кто-то ещё, а может он почти всегда. gorm.Expr из gorm.go разрубает узел: func Expr(expr string, args ...interface{}) clause.Expr — выражение передаётся базе как значение, и SET priority = priority + 1 вычисляет СУБД в момент исполнения над актуальной строкой. Один запрос, никакого чтения, никакого окна для гонки. Аргументы Expr едут плейсхолдерами и экранируются драйвером — то же правило седьмого урока: переменные живут в аргументах, а не в строке выражения. Форма работает везде, где ждут значение: в Update, в карте Updates, в UpdateColumn и UpdateColumns. Внутри выражения — любой SQL, который поймёт ваш диалект: арифметика, функции, CASE. Переносимость выражения — забота ваша: GORM текст выражения не разбирает и не проверяет.

### Инкременты и декременты

```go
// Гонка: так делать не надо
var task TodoItem
db.First(&task, 3)
db.Model(&task).Update("priority", task.Priority+1)
// между First и Update сосед успел поднять приоритет - его правка затёрта

// Атомарно: вычисление уезжает в СУБД
db.Model(&task).Update("priority", gorm.Expr("priority + ?", 1))
// UPDATE "todo_items" SET "priority"=priority+1,"updated_at"='...' WHERE id = 3

// То же картой Updates - выражение легально среди обычных значений
db.Model(&task).Updates(map[string]interface{}{
        "status":   "doing",
        "priority": gorm.Expr("priority + ?", 1),
})
// UPDATE ... SET "priority"=priority+1,"status"='doing',"updated_at"='...' WHERE id = 3

// Батч: поднять приоритет всем открытым задачам одним запросом
db.Model(&TodoItem{}).Where("status IN ?", []string{"todo", "doing"}).
        Update("priority", gorm.Expr("priority + ?", 1))
// UPDATE ... SET "priority"=priority+1 WHERE status IN ('todo','doing')
```

След атомарного запроса стоит рассмотреть медленно: справа от знака равенства не число, а ссылка на колонку — формула, которую база вычислит над текущей строкой. Выражение можно смешивать с обычными значениями в одной карте: каждая пара независимо решает, значение это или формула. Батч-вариант — рабочая замена циклу из N запросов: одна строка SQL, один поход в базу.

### Конкурентные правки: оптимистичная блокировка через версию

Инкремент закрыл арифметику, но правки бывают сложнее: два менеджера редактируют одну задачу в разных окнах, оба жмут «Сохранить» — чья версия переживёт, та и останется, вторая сгинет молча. Это потерянное обновление, и у него два канонических ответа. Первый — оптимистичная блокировка: к модели добавляется счётчик версий, читаем его вместе с данными, а при записи ставим двойное условие — ключ и та самая версия — и наращиваем счётчик атомарно через gorm.Expr. Кто-то успел раньше? Его запрос уже поднял версию, наше Where("version = ?") промахнулось, RowsAffected = 0 — и мы честно узнаём о конфликте вместо тихой порчи данных. Никаких блокировок СУБД не держит, отсюда имя «оптимистичная»: рассчитываем, что конфликтов мало, и проверяем постфактум. Поле добавить легко: Version int в структуру задачи — AutoMigrate из четвёртого урока добавит колонку сам. Схема хорошо ложится на веб: перечитать и повторить — штатный путь, а не авария; данные читаются без транзакций; конфликты редки, потому что разные клиенты правят разные записи. Держите её как дефолт, а пессимистичную из следующего раздела оставьте горячим строкам.

### Оптимистичная блокировка на версии

```go
// В модель добавлено поле Version int - счётчик конкурентных правок
// (AutoMigrate из четвёртого урока добавит колонку сам)

func closeTask(db *gorm.DB, id uint) error {
        var task TodoItem
        // Читаем задачу вместе с её текущей версией
        if err := db.First(&task, id).Error; err != nil {
                return err
        }

        // Записываем только если версия не изменилась с момента чтения
        result := db.Model(&TodoItem{}).
                Where("id = ? AND version = ?", task.ID, task.Version).
                Updates(map[string]interface{}{
                        "status":  "done",
                        "version": gorm.Expr("version + ?", 1), // наращиваем атомарно
                })
        if result.Error != nil {
                return result.Error
        }
        if result.RowsAffected == 0 {
                // Кто-то сохранил раньше: версия уже другая
                return errors.New("конфликт версий: перечитайте задачу и повторите")
        }
        return nil
}
```

Весь паттерн — один UPDATE: и проверка версии, и запись нового значения, и её инкремент уезжают вместе, между ними никто не вклинится. RowsAffected == 0 здесь имеет ровно один смысл: задачу правили параллельно. Промышленная версия паттерна зацикливает перечитать-повторить с лимитом попыток; для API ответ «данные изменились, обновите форму» — тоже легальный исход.

### Пессимистичная блокировка: SELECT FOR UPDATE

Второй ответ — пессимистичная блокировка: заблокировать строку до правки, чтобы конкурент даже не пытался. Рецепт: транзакция, внутри — чтение с клозом блокировки tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&task, 3), и SELECT уезжает в базу с хвостом FOR UPDATE: строка заблокирована до COMMIT или ROLLBACK, конкурентная транзакция с таким же FOR UPDATE повиснет на своём чтении и дождётся нашей. Клоз описан в clause/locking.go: поле Strength принимает константы LockingStrengthUpdate ("UPDATE") и LockingStrengthShare ("SHARE" — FOR SHARE, блокировка от изменений, но не от чтений), поле Options — LockingOptionsNoWait ("NOWAIT") и LockingOptionsSkipLocked ("SKIP LOCKED"): не ждать занятую строку, а сразу ошибиться или молча пропустить. Заметьте, что FOR UPDATE едет в SELECT: клоз FOR входит в сборку запроса на чтение, а правка потом делается обычным Update — блокирует чтение, а не запись. Транзакция не опция, а обязательное условие: без неё SELECT живёт в автокоммите и замок рассыпается сразу после чтения. Выбор между схемами прагматичен: короткие правки и редкие конфликты — оптимистичная; горячая строка, которую щупают все воркеры одновременно, — пессимистичная, чтобы конкуренция выстроилась в очередь, а не в бесконечные повторы.

### FOR UPDATE внутри транзакции

```go
err := db.Transaction(func(tx *gorm.DB) error {
        var task TodoItem
        // SELECT * FROM "todo_items" WHERE id = 3 ORDER BY ... LIMIT 1 FOR UPDATE
        if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&task, 3).Error; err != nil {
                return err
        }

        // Строка заблокирована до конца транзакции: конкурентный FOR UPDATE подождёт
        if task.Status == "done" {
                return errors.New("задача уже закрыта")
        }
        return tx.Model(&task).Update("status", "done")
})
if err != nil {
        log.Println("не получилось:", err)
}
```

Два правила кода. Первое: внутри колбэка работаем с tx, а не с db — только соединение транзакции держит замок; уйдёте на db — заблокируете строку в одном соединении, а правите в другом. Второе: FOR UPDATE в следе сидит в самом SELECT — правка отдельным Update уже ни о чём не беспокоится. Нужен сценарий «не ждать» — добавьте Options: "SKIP LOCKED" в тот же клоз: занятая строка молча выпадет из выборки, так строят очереди воркеров.

### Шпаргалка: методы обновления

| Вызов | Что в SET | updated_at и хуки | Нюанс |
| --- | --- | --- | --- |
| Model(&task).Update("col", v) | одна колонка | авто, хуки работают | WHERE из ключа модели |
| Model(...).Where(...).Update(...) | одна колонка, батч | авто, хуки работают | без условия — ErrMissingWhereClause |
| Updates(struct) | ненулевые поля | авто, хуки работают | zero values молча выпадают |
| Updates(map) | все ключи, с нулями | авто, если updated_at нет в карте | колонки в SET по алфавиту |
| Select("Done").Updates(...) | перечисленное, даже нули | авто | рестрикт: остальное не поедет |
| UpdateColumn / UpdateColumns | одна колонка / пачка | не трогаются, SkipHooks | WHERE всё равно обязателен |
| Update("col", gorm.Expr(...)) | SQL-выражение | авто | атомарно, без гонки читать-писать |
| Clauses(Locking{...}).First(...) | SELECT ... FOR UPDATE | — | только внутри транзакции |

### Заполните пропуски: атомарность и проверка результата

Восстановите атомарный инкремент, карту с честным нулём и проверку затронутых строк.

```go
// 1) Поднять приоритет задачам в работе - атомарно
db.Model(&TodoItem{}).Where("status = ?", "doing").
        Update("priority", gorm.___("priority + ?", ___))

// 2) Переписать задачу картой: нулевые значения едут честно
db.Model(&task).Updates(map[string]interface{}{"___": false, "status": "todo"})

// 3) Проверить, что строка нашлась и версия совпала
if result.___ == 0 {
        log.Println("задача не найдена или версия не совпала")
}
```

Подсказка: Выражение для SET даёт gorm.Expr, шаг инкремента — значение плейсхолдера; карта, в отличие от структуры, уважает false; за число затронутых строк отвечает поле результата RowsAffected.

Правильные ответы в пропусках: `Expr`, `1`, `done`, `RowsAffected`

### Соберите путь UPDATE по шагам

Расставьте шаги, которые проходят данные при вызове Model(&task).Update("status", "done").

Правильный порядок строк:

1. `Model(&task) положил модель в Statement.Model, Update("status", "done") — карту колонки в Statement.Dest`
2. `ConvertToAssignments собрал SET: status = 'done' плюс подмена updated_at текущим временем`
3. `Первичный ключ модели вынесен из SET в условие WHERE id = 3`
4. `checkMissingWhereConditions проверил: WHERE не пуст, запрет глобальных обновлений не сработал`
5. `Хуки BeforeSave и BeforeUpdate выполнены, колбэк Update собрал SQL по клозам UPDATE, SET, WHERE`
6. `Запрос ушёл через ConnPool, database/sql вернул число затронутых строк`
7. `RowsAffected и Error разложены по результату, отработали AfterSave и AfterUpdate`

### Карточки: обновление записей

- **Update("col", v)** — Одно поле: Model(&task) даёт WHERE id из ключа, Where() — своё условие. Хуки и автоправка updated_at работают. Без условия — ErrMissingWhereClause («WHERE conditions required»), запрос в базу не уходит.
- **Updates(struct) vs Updates(map)** — Структура: только ненулевые поля — Done: false молча выпадет. Карта: едут все ключи с нулями, колонки в SET идут по алфавиту. updated_at дописывается сам, если его нет в карте.
- **Select и Omit в Updates** — Select("Done") форсирует поле даже нулевое — лекарство от zero-ловушки структуры; Select("*") — вся структура. Omit вычёркивает колонку. Это белый список для SET, зеркало Specify Struct search fields.
- **UpdateColumn / UpdateColumns** — То же, но SkipHooks: ни BeforeSave/BeforeUpdate, ни AfterSave/AfterUpdate, ни автоматического updated_at. Для счётчиков, аудита и служебных правок. WHERE всё равно обязателен.
- **gorm.Expr** — SQL-выражение как значение: Update("priority", gorm.Expr("priority + ?", 1)) — атомарный инкремент на стороне СУБД, без схемы «прочитал — посчитал — записал». Аргументы экранируются, работает и в карте Updates.
- **Оптимистичная и пессимистичная блокировка** — Оптимистичная: поле Version, Where("id = ? AND version = ?") и gorm.Expr("version + ?", 1); RowsAffected == 0 — конфликт, перечитать. Пессимистичная: транзакция + Clauses(clause.Locking{Strength: "UPDATE"}) на SELECT — FOR UPDATE держит строку до коммита.

### Что дальше по курсу

CRUD почти собран: C из пятого урока, R из шестого и седьмого, U из этого — осталась D, и она интереснее, чем кажется. [Девятый урок — Удаление и мягкое удаление](/courses/gorm/lessons/09-delete/): Delete по ключу и по условию, поле gorm.DeletedAt, из-за которого «удалённые» строки продолжают жить в таблице, Unscoped для настоящей чистки и даже восстановление; там же станет понятен deleted_at IS NULL, мелькавший в этом уроке одной фразой. Если нужно освежить формы условий, вернитесь к [седьмому уроку про Where](/courses/gorm/lessons/07-where/); чтение, First и ErrRecordNotFound — в [шестом уроке](/courses/gorm/lessons/06-read/); полный план из 25 уроков — на [странице курса](/courses/gorm/). Домашка ниже собирает все финишеры обновления и обе блокировки в один скрипт.

## Коротко: что запомнить

- Update("col", v) обновляет одно поле: WHERE приходит из первичного ключа Model(&task) или из явного Where; без условия GORM вернёт ErrMissingWhereClause и в базу не пойдёт.
- Updates со структурой берёт только ненулевые поля — Done: false молча выпадет; карта включает нули честно, а её ключи в SET идут по алфавиту.
- Select("Done") в обновлении форсирует перечисленное поле даже нулевое, Omit вычёркивает колонку — белый список для SET, зеркало Specify Struct search fields.
- UpdateColumn и UpdateColumns ставят SkipHooks: ни BeforeSave/BeforeUpdate, ни AfterSave/AfterUpdate, ни автоправки updated_at — для счётчиков, аудита и служебных правок.
- RowsAffected — сколько строк реально затронуто; ноль строк — не ошибка, а сигнал «не нашлось» или «проиграли гонку»; MySQL по умолчанию считает только изменённые, PostgreSQL — все затронутые.
- gorm.Expr отдаёт базе выражение: Update("priority", gorm.Expr("priority + ?", 1)) атомарен — SET вычисляет СУБД над актуальной строкой, окна для гонки нет.
- Конкурентные правки: оптимистичная блокировка — поле Version, Where("id = ? AND version = ?") и проверка RowsAffected == 0; пессимистичная — транзакция и Clauses(clause.Locking{Strength: "UPDATE"}) на SELECT, то есть FOR UPDATE до коммита.

## Домашнее задание

Соберите локальный скрипт поверх таблицы из пятого урока. Закройте задачу 3 через Model(&task).Update("status", "done") и найдите в следе WHERE id = 3 и подмену updated_at. Попробуйте закрыть всё одним Update без условий — прочитайте ErrMissingWhereClause, затем обойдите его тремя способами: Where("1 = 1"), db.Exec, Session с AllowGlobalUpdate — и сравните RowsAffected каждого. Обновите задачу 2 структурой с Done: false и убедитесь, что поле промолчало; почините картой и Select("Done"). Поднимите приоритет всем открытым задачам через gorm.Expr("priority + ?", 1) одним запросом. Смоделируйте конфликт версий: два чтения задачи и две правки подряд — вторая должна получить RowsAffected == 0. Финал: повторите ту же правку через транзакцию с clause.Locking{Strength: "UPDATE"} и найдите FOR UPDATE в SQL-следе.

## Вопросы и ответы по уроку

### Почему Updates со структурой не обновляет поле, которому присвоили ноль?

Структурное обновление собирается из ненулевых полей: false, 0 и пустая строка считаются «не заполнено» — то же isZero-правило, что и в структурных условиях седьмого урока. Нужен честный ноль — Updates с map[string]interface{}, где карта не отличает «пусто» от «не заполнял», или Select("Поле") с перечислением нужной колонки.

### Как обновить все строки таблицы, если GORM требует WHERE?

Три легальных пути: тривиальное условие Where("1 = 1"), сырой SQL через db.Exec (ввод пользователя — только аргументами, строка Exec не экранируется) или Session(&gorm.Session{AllowGlobalUpdate: true}) для осознанного глобального обновления. Без всего этого Update вернёт ErrMissingWhereClause с текстом «WHERE conditions required» и не отправит запрос в базу.

### Когда брать UpdateColumn вместо Update?

Когда правка служебная: счётчики, аудит, синхронизация данных — там, где не нужны хуки BeforeSave/BeforeUpdate/AfterSave/AfterUpdate и не должно двигаться updated_at. Внутри UpdateColumn стоит SkipHooks. Условие всё равно обязательно: защита от глобальных обновлений через checkMissingWhereConditions действует и на него.
