---
title: "Создание сущностей"
description: "ent create на практике: client.User.Create(), Save и SaveX, SetNillable, CreateBulk одним INSERT, upsert через OnConflict и ловля дублей UniqueConstraintError."
course: "ent: графовая ORM с кодогенерацией"
url: "https://golangmaster.ru/courses/ent/lessons/08-sozdanie/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Создание сущностей

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

Семь уроков мы готовили сцену: схемы User и Group, поля с валидаторами, рёбра, карта каталога ent/. Сегодня — первое настоящее действие над базой: создание ent-сущностей. Форма вызова непривычна для пришедших из других ORM: client.User.Create() не принимает ни контекста, ни готовой структуры — из него выходит билдер, а исполнение начинается только в Save(ctx). Тема урока — ent create целиком: сеттер-стиль, три контракта ошибки Save, SaveX и Exec, SetNillable-сеттеры для значений-указателей, связи при создании одной цепочкой, пакетная вставка CreateBulk и upsert через OnConflict для сценария «нет — вставь, есть — обнови». Финал — ошибки во всех проявлениях: валидация до базы, дубли уникального индекса и отменённый контекст.

Мы вызывали client.User.Create() начиная с третьего урока — в квизах, в predict-задачах, в цепочках с AddUsers из [шестого урока](/courses/ent/lessons/06-edges-m2m/). Но делали это вслепую: билдер был чёрным ящиком, который почему-то исполняется методом Save. Сегодня вскрываем ящик и проходим ent create от формы вызова до классификации ошибок. Сначала форма: почему Create() не принимает ни контекста, ни структуры и что именно он возвращает. Затем исполнение: Save, SaveX, Exec и ExecX — четыре метода, два контракта ошибки. Потом арсенал: SetNillable для nil-значений из API-слоя, связи при создании, пакетная вставка CreateBulk одним INSERT и upsert через OnConflict — включённый в седьмом уроке флаг sql/upsert наконец находит своё применение. И в конце — бухгалтерия ошибок: где ent проверяет поля, как он классифицирует дубли уникального индекса и что происходит с отменённым контекстом. Создание ent после этого урока перестанет быть вызовом по памяти и станет осознанной операцией.

### ent create: билдер вместо готовой структуры

Главная привычка, которую придётся поменять: в ent нет формы Create(ctx, *User), принимающей готовую структуру. client.User.Create() вызывается без единого аргумента и возвращает *UserCreate — билдер, внутри которого живёт UserMutation в режиме OpCreate. Дальше вы метод за методом наполняете мутацию: SetName записывает поле, AddGroupIDs добавляет ребро. Контекст появляется только на исполнении — Save(ctx). Это не случайность, а разделение ролей: сборка и исполнение — разные шаги. Билдер можно собрать один раз и исполнить в двух разных контекстах, отдать в функцию, которая дополнит его своими полями, повесить на него хук — мутация и есть то, на что подписываются хуки семнадцатого урока. А привычку «создать из структуры» ent предлагает заменить тем, что вы перекладываете поля сеттерами: компилятор проверит каждое, и ни одно не потеряется молча. Ещё одна особенность станет важна ниже: у билдера нет метода ID(ctx) — он появится только после OnConflict(), потому что «вернуть id вставленного» осмысленно и для обновлённой строки, а это уже другой тип билдера.

### Сеттер-стиль: Create().SetName(...).Save(ctx)

```go
// client.User.Create() не принимает ни ctx, ни структуры —
// он возвращает билдер, исполнение начинается в Save(ctx):
anna, err := client.User.
        Create().                    // *UserCreate с мутацией OpCreate
        SetName("anna").             // обязательное поле из схемы
        SetEmail("anna@entgo.io").   // email — уникальный
        SetAge(28).                  // Positive-валидатор из третьего урока
        SetWebsite("https://anna.dev"). // Optional-поле из четвёртого
        Save(ctx)                    // INSERT и возврат (*ent.User, error)
```

Порядок сеттеров свободный — вы наполняете мутацию, а не вызовы. Save возвращает пару: сущность с присвоенным ID и ошибку. Обязательное поле name и Unique-поле email взяты из схемы второго урока; забудете SetName — ошибка придёт ещё до базы (ниже покажем, прежде каких этапов). Вся цепочка — обычный Go: опечатка в имени сеттера — ошибка компиляции, а не рантайма. Это тот заслон, ради которого ent и генерирует билдеры вместо строковых колонок.

### Save, SaveX, Exec, ExecX: два контракта ошибки

У каждого create-билдера четыре метода исполнения, и различаются они двумя осями: вернуть сущность или нет, вернуть ошибку или паниковать. Save(ctx) — основной: (*User, error), полный контроль в руках вызывающего. SaveX(ctx) — то же самое, но вместо ошибки паника: документация описывает его одной фразой «SaveX calls Save and panics if Save returns an error». Exec(ctx) — для случаев, когда сущность не нужна: метод возвращает только error, это удобный финал для цепочек-побочек вроде записи лога или факта «пользователь кликнул». ExecX(ctx) — Exec с паникой. Правило выбора простое: где ошибку обрабатывают — там Save и Exec (HTTP-хендлеры, воркеры), где ошибке нечего делать — там SaveX (инициализация приложения, сидинг демо-данных, тесты, CLI-утилиты). Паника из SaveX в HTTP-хендлере превратится в 500 через recovery-механизм фреймворка — переживёт пользователь, но не ваша логика повторов. Поэтому в прикладном коде каноничен Save, а SaveX — рабочая лошадка кода запуска и тестов.

### SetNillable: сеттеры, которые не боятся nil

API-слои любят указатели: поле формы пришло как *string, и непонятно, то ли это «пусто», то ли значение. Для таких случаев генератор выпускает SetNillable-сеттеры: SetNillableWebsite(p *string) устанавливает поле, только если p не nil, — в документации это звучит как «Avoid nil checks»: одну if-проверку на каждое поле срезает сам сеттер. Передали nil — сеттер молча пропустит, поле останется незаданным, и для Optional-колонки это законный исход: Save пройдёт, в базе будет NULL. SetNillable генерируется у полей с Optional и у полей с Default — роль SetNillableRole(*string) существует, хотя сама роль обязательна: nil лишь значит «возьми дефолт». Здесь же честная тонкость, дополняющая таблицу четвёртого урока: у Nillable-полей отдельный SetNillable не генерируется вовсе. Для bio, объявленного Optional().Nillable(), сеттер SetBio уже принимает *string — null-safe-версия была бы полным дублем, и в шаблоне генератора этот случай исключён комментарием про то, что nillable-сеттеры для nillable-типов не нужны. Итого: website — пара SetWebsite(string) и SetNillableWebsite(*string); bio — только SetBio(*string) плюс ClearBio.

### Проверка: nil в SetNillable

**Вопрос:** website объявлен в схеме как Optional (без Nillable). Что произойдёт при вызове client.User.Create().SetName("vera").SetEmail("vera@entgo.io").SetNillableWebsite(p).Save(ctx), если p — nil *string?

- Save вернёт ошибку: поле website обязательно для заполнения
- ✅ Поля не будет: SetNillable молча пропустит nil, Save пройдёт, в колонке — NULL
- Паника: разыменование nil-указателя внутри сеттера
- В базу уйдёт пустая строка: nil приведётся к ""

SetNillableWebsite проверяет указатель и при nil просто не записывает значение в мутацию — ни ошибки, ни паники, ни подмены пустой строкой. Дальше работает обычная логика Optional-поля из четвёртого урока: значение не задано, колонка уходит как NULL, сущность в Website получит "" при чтении. Проверки «обязательности» у Optional-полей нет по определению.

### Связи при создании: AddGroups одной цепочкой

```go
// группы g1 и g2 уже созданы и лежат в переменных:
anna, err := client.User.
        Create().
        SetName("anna").
        SetEmail("anna@entgo.io").
        AddGroups(g1, g2).   // M2M: две строки в group_users
        Save(ctx)

// вариант по идентификаторам — без структур на руках:
boris, err := client.User.
        Create().
        SetName("boris").
        SetEmail("boris@entgo.io").
        AddGroupIDs(g1.ID, 42).
        Save(ctx)
```

AddGroups и AddGroupIDs — та же пара «по сущностям и по идентификаторам», что у AddUsers в шестом уроке, только с обратной стороны ребра groups: обратная половина тоже получает свои Add-мутаторы в user_create.go. Под капотом sqlgraph это несколько операций: INSERT в users, затем вставки в join-таблицу group_users. Важная деталь исходников: как только в операции появляются «внешние» рёбра, ent сам открывает транзакцию (внутри это mayTx), исполняет вставку узла и связи единым блоком и откатывает всё при любой ошибке. Вручную оборачивать такой create в Tx не нужно — транзакция уже есть.

### Забытое поле: какая ошибка вернётся

```go
// в схеме: age — обязательное поле с валидатором,
// email — Unique, name — обязательное

u, err := client.User.
        Create().
        SetName("boris").
        SetEmail("boris@entgo.io").
        // SetAge забыли
        Save(ctx)

fmt.Println(u, err)
```

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

- ✅ u = nil, err = "ent: missing required field \"User.age\"" — ошибка валидации, до базы дело не дошло
- u = nil, err от СУБД: NOT NULL constraint на колонке age
- u ≠ nil: пользователь создан с нулевым возрастом
- Паника внутри Save: обязательное поле не задано

Порядок этапов в Save жёсткий: сначала defaults() подставляет значения по умолчанию (created_at из time.Now), затем сработают хуки, затем check() — и вот здесь обязательные поля и валидаторы из схемы проверяются без единого похода в базу. Ошибка оборачивается в ValidationError с именем поля, сообщение — «ent: missing required field "User.age"». СУБД своей NOT NULL-проверки не увидит вообще: ent отсекает неполные мутации раньше. Это тот же валидатор, что ловил квиз третьего урока, — теперь видно, на каком этапе он стоит.

### CreateBulk: пакетная вставка одним INSERT

Десять пользователей по одному Save — это десять INSERT и десять кругов до базы. CreateBulk собирает их в один. Точная сигнатура из сгенерированного клиента: client.User.CreateBulk(builders ...*UserCreate) *UserCreateBulk — метод принимает уже собранные create-билдеры, а его собственный Save возвращает ([]*User, error): сущности в том же порядке, что и билдеры. Внутри это sqlgraph.BatchCreate: один INSERT с несколькими кортежами VALUES. Следствия читаются прямо из этой механики. Если билдеры задали разные наборы полей, ent выровняет колонки, заполнив пропуски NULL-ами — INSERT требует одинаковой арки. Если ID задан хоть у одного билдера, он обязан быть у всех — иначе ошибка «inconsistent id values for batch insert»: частично заданные идентификаторы ломают расчёт id на MySQL и SQLite. Ошибка в любой строке пакета — ошибка всей операции: стейтмент один, «полу-вставки» не существует. А если у билдеров есть рёбра, ent снова откроет транзакцию сам: узлы и join-строки покатятся вместе. Для типового сценария «вставить всех из среза» есть второй вход — MapCreateBulk, он же и есть способ не собирать билдеры руками.

### CreateBulk и MapCreateBulk: два входа в пакет

```go
// вход 1: билдеры собраны заранее, каждый по-своему:
users, err := client.User.CreateBulk(
        client.User.Create().SetName("anna").SetEmail("anna@entgo.io"),
        client.User.Create().SetName("boris").SetEmail("boris@entgo.io"),
).Save(ctx) // []*ent.User в порядке билдеров

// вход 2: MapCreateBulk строит билдеры из вашего среза сам:
names := []string{"clara", "dave", "vera"}
users, err := client.User.MapCreateBulk(names, func(c *ent.UserCreate, i int) {
        c.SetName(names[i]).SetEmail(names[i] + "@entgo.io")
}).Save(ctx)
```

MapCreateBulk(slice any, setFunc func(*ent.UserCreate, int)) пробегает срез с индексом и передаёт вам свежий билдер на каждый элемент — вместо ручного списка получается один колбэк. Аргумент slice нетипизирован (any): передадите не срез — ошибка уже на Save с текстом про «wrong type, need slice». Оба входа заканчиваются одинаково: один INSERT, все-или-ничего. Имена в пакетах продублировать нельзя: уникальный email в двух строках одного INSERT уронит весь пакет с ошибкой констрейнта — как её ловить, увидим ниже.

### Проверка: атомарность пакета

**Вопрос:** Пять билдеров ушли в CreateBulk().Save(ctx). У третьего email уже занят в базе. Что лежит в базе и в переменных после вызова?

- Четыре строки вставлены, err nil, users без третьего элемента
- ✅ Ни одна строка не вставлена: users = nil, err — ошибка констрейнта
- Три строки вставлены, err nil, ошибка потеряна
- Все пять вставлены, но третий получил пустой email

CreateBulk — это один INSERT с пятью кортежами VALUES. Констрейнт уникальности не пустит третью строку — а стейтмент либо исполняется целиком, либо нет: частичных результатов у одного INSERT не бывает. В переменных users = nil и ошибка, обёрнутая в ent.ConstraintError. Если бы у билдеров были рёбра, авто-транзакция откатила бы и вставку узлов, и записи в join-таблицу.

### OnConflict: upsert «есть — обнови»

Регистрация по email — сценарий «нет пользователя — вставь, есть — обнови». В SQL это INSERT ... ON CONFLICT (PostgreSQL, SQLite) или INSERT ... ON DUPLICATE KEY UPDATE (MySQL), в ent — методы из флага sql/upsert, который мы включали и изучали в [седьмом уроке](/courses/ent/lessons/07-generate/). Метод OnConflict(opts ...sql.ConflictOption) вызывается на create-билдере, но после него цепочка меняет тип: OnConflict() возвращает *UserUpsertOne — отдельного upsert-билдера, и обычные SetX на нём уже недоступны. Повторный вызов OnConflict на том же билдере даёт ошибку «ent: OnConflict was set for builder» — флаг ставится один раз. Методы нового билдера: UpdateNewValues() — при конфликте обновить изменяемые поля значениями из вставки (Immutable-поля вроде created_at и заданный вручную ID генератор честно исключает); Ignore() — «каждой колонке её же значение», то есть фактически ничего не менять; DoNothing() — честный DO NOTHING, доступный только SQLite и PostgreSQL: в MySQL такого нет, ent подменяет его Ignore-вариантом; Update(func(u *ent.UserUpsert)) — полный контроль над SET-частью. Пустой OnConflict() без цели конфликта MySQL поймёт (ON DUPLICATE KEY цели не имеет), а PostgreSQL потребует указать цель — об этом отдельный predict ниже. Исполнение — Exec(ctx) или ID(ctx): второй вернёт идентификатор вставленной или обновлённой строки.

### OnConflict: три рабочие формы

```go
// 1. есть — обнови новыми значениями из вставки:
err := client.User.
        Create().
        SetEmail("anna@entgo.io").
        SetName("anna-updated").
        OnConflictColumns(user.FieldEmail). // цель конфликта
        UpdateNewValues().                  // mutable-поля из вставки
        Exec(ctx)

// 2. есть — обнови, но одно поле переопредели:
err := client.User.
        Create().
        SetEmail("boris@entgo.io").
        SetName("boris").
        OnConflictColumns(user.FieldEmail).
        UpdateNewValues().
        Update(func(u *ent.UserUpsert) {
                u.SetName("boris-returned") // свой SET поверх
        }).
        Exec(ctx)

// 3. вставь или обнови — и верни id строки:
id, err := client.User.
        Create().
        SetEmail("clara@entgo.io").
        SetName("clara").
        OnConflictColumns(user.FieldEmail).
        UpdateNewValues().
        ID(ctx) // id вставленной ИЛИ обновлённой
```

user.FieldEmail — константа "email" из пакета ent/user. UpdateNewValues внутри — это sql.ResolveWithNewValues() плюс исключения для Immutable-полей: created_at не перепишется, хотя значение в вставке есть. Update(func(u *ent.UserUpsert)) даёт весь словарь: SetX — своё значение, UpdateX — взять значение из вставки (внутри SetExcluded), AddX — прибавить к текущему, ClearX — в NULL. Форма 3 — самая частая в REST-обработчиках: один вызов вместо «сначала SELECT, потом INSERT или UPDATE». За кулисами ID(ctx) на PostgreSQL отработает через RETURNING, а на MySQL ent добавит в ON DUPLICATE KEY UPDATE выражение LAST_INSERT_ID(...) — иначе после конфликта MySQL вернул бы id 0. Для нечисловых (строковых, UUID) ID на MySQL ID(ctx) не поддержан — только Exec.

### PostgreSQL без цели конфликта

```go
// диалект postgres, флаг sql/upsert включён.
// email "anna@entgo.io" уже есть в базе.

err := client.User.
        Create().
        SetEmail("anna@entgo.io").
        SetName("anna-the-second").
        OnConflict().          // цель конфликта не указана
        UpdateNewValues().
        Exec(ctx)
```

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

- Выполнится: PostgreSQL сам найдёт подходящий уникальный индекс
- ✅ Ошибка от PostgreSQL: для DO UPDATE требуется цель конфликта — колонки или констрейнт
- Обновятся все строки таблицы users
- Выполнится тихо: строка не изменится вовсе

Документация фиксирует: «In PostgreSQL, the conflict target is required». Пустой ON CONFLICT без цели допустим только для DO NOTHING; для DO UPDATE СУБД обязана знать, по какому индексу разрешать конфликт, — иначе вернёт ошибку вида «there is no unique or exclusion constraint matching the ON CONFLICT specification». Лечение уже показано: OnConflictColumns(user.FieldEmail) — или SQL-API: OnConflict(sql.ConflictColumns(user.FieldEmail)), а при именованных констрейнтах — sql.ConflictConstraint("users_email_key"). MySQL в этой сцене не пострадал бы: у ON DUPLICATE KEY цели не бывает по определению.

### Дубликат: как ent отдаёт ошибку уникальности

Email в схеме Unique — второй пользователь с тем же адресом упрётся в уникальный индекс, и текст ошибки придёт от драйвера: MySQL скажет «Error 1062: Duplicate entry 'anna@entgo.io' for key 'users.email'», PostgreSQL — «duplicate key value violates unique constraint "users_email_key"», SQLite — «UNIQUE constraint failed: users.email». ent классифицирует такие ошибки в пакете dialect/sql/sqlgraph: функция IsUniqueConstraintError ищет в тексте ошибки маркеры всех трёх диалектов — именно те три строки, что выше. Параллельно есть более широкий IsForeignKeyConstraintError с маркерами нарушений внешних ключей. Дальше работает сгенерированный код: любой constraint-сбой оборачивается в тип ent.ConstraintError — его Error() даёт «ent: constraint failed: » плюс исходный текст, а Unwrap отдаёт оригинал, так что цепочка errors.Is и errors.As сохраняется целиком. Итого два уровня проверки на вашей стороне: ent.IsConstraintError(err) через errors.As — «это нарушение какого-либо констрейнта», и sqlgraph.IsUniqueConstraintError(err) — «это именно дубль». Честная деталь механизма: классификация строковая, по тексту драйвера, — поэтому ent и матчит только свои три маркера на диалект, а не пытается парсить всё подряд.

### Ловим дубль email: два уровня проверки

```go
import (
        "blog/ent"
        "entgo.io/ent/dialect/sql/sqlgraph"
)

_, err := client.User.
        Create().
        SetName("anna").
        SetEmail("anna@entgo.io"). // адрес уже занят
        Save(ctx)

switch {
case err == nil:
        // вставка прошла
case ent.IsConstraintError(err): // errors.As: обёртка ent.ConstraintError
        if sqlgraph.IsUniqueConstraintError(err) { // именно дубль индекса
                // вернём форме «email занят», а не 500
        } else {
                // другой констрейнт: например, внешний ключ
        }
default:
        // остальное: соединение, диалект, контекст
}
```

ent.ConstraintError — единственная обёртка, которую ent добавляет к ошибкам создания: сообщение начинается с «ent: constraint failed: », а Unwrap отдаёт текст драйвера целиком — в лог попадёт и имя индекса, и значение. sqlgraph.IsUniqueConstraintError не строит цепочку через errors.As — он проверяет текст ошибки, и обёртка ему не мешает: исходный текст внутри. Функциональные обработчики ошибок валидации уже видели: IsValidationError из семейства тех же сгенерированных хелперов — порядок проверок «валидация, потом констрейнты» отражает порядок этапов Save.

### Проверка: классификация ошибок

**Вопрос:** Создание поста упало: author_id ссылается на удалённого пользователя, драйвер вернул нарушение внешнего ключа. Что вернут ent.IsConstraintError(err) и sqlgraph.IsUniqueConstraintError(err)?

- true и true: обе функции ловят любые ошибки базы
- false и true: внешний ключ — это уникальность в другой форме
- ✅ true и false: ConstraintError покрывает констрейнты вообще, unique — только дубли
- false и false: для внешних ключей у ent вообще нет классификации

Внешний ключ — тоже констрейнт: сгенерированный код обернёт его в ent.ConstraintError, и IsConstraintError вернёт true (внутри — те же errors.As плюс собственно констрейнт-маркеры sqlgraph). Но IsUniqueConstraintError ищет только маркеры дублей — «Error 1062», «violates unique constraint», «UNIQUE constraint failed» — и на тексте внешнего ключа («violates foreign key constraint», «FOREIGN KEY constraint failed») даст false. Для FK в sqlgraph есть отдельная IsForeignKeyConstraintError.

### Отменённый контекст: что вернёт Save

Контекст в ent — сквозной: Save(ctx) передаёт его драйверу, и отмена добирается до уровня database/sql. Клиент закрыл вкладку, дедлайн истёк — драйвер вернёт context.Canceled или context.DeadlineExceeded, и ent их не заворачивает и не переписывает: обычная проверка errors.Is(err, context.Canceled) работает без сюрпризов. Полезно знать порядок этапов, чтобы понимать, что успеет произойти до отмены. Сборка дефолтов, хуки и check() с валидацией — чистый Go, контекст им не нужен: невалидный билдер вернёт ValidationError даже на уже отменённом контексте, потому что до базы дело просто не дойдёт. И наоборот: идеально собранная вставка на отменённом контексте упадёт на драйвере, ни одна строка не будет записана. С пакетами та же логика плюс атомарность: один INSERT не умеет частично исполниться, а пакет с рёбрами накрывает всё авто-транзакцией — отмена на середине откатит и узлы, и join-строки. Поэтому повтор на свежем контексте — безопасная стратегия: ent не оставляет обломков между вызовами.

### Арсенал создания: метод, сценарий, возврат

| Вызов | Сценарий | Возврат |
| --- | --- | --- |
| client.User.Create().SetX(...).Save(ctx) | одна сущность, ошибку обрабатываем | (*ent.User, error) |
| client.User.Create().SetX(...).SaveX(ctx) | одна сущность, ошибка = паника (запуск, тесты) | *ent.User |
| client.User.Create().SetX(...).Exec(ctx) | факт вставки, сущность не нужна | error |
| client.User.CreateBulk(b1, b2).Save(ctx) | пакет собранных билдеров одним INSERT | ([]*ent.User, error) |
| client.User.MapCreateBulk(slice, fn).Save(ctx) | пакет из среза: билдеры строит ent | ([]*ent.User, error) |
| ...OnConflictColumns(...).UpdateNewValues().ID(ctx) | upsert: вставь или обнови и верни id | (int, error) |

### Шесть правил создания в ent

- client.User.Create() вызывается без аргументов: контекст живёт в Save/Exec/ID, а не в конструкторе билдера.
- Обязательные поля и валидаторы проверяются до базы: ValidationError с именем поля и сообщением «missing required field».
- Значения-указатели из API отдавайте SetNillable: nil тихо пропустится; у Nillable-полей SetNillable не нужен — там SetX уже принимает указатель.
- Дубли ловите в два слоя: ent.IsConstraintError для констрейнтов вообще, sqlgraph.IsUniqueConstraintError для дублей; текст драйвера сохранён в Unwrap.
- Пакет — CreateBulk или MapCreateBulk: один INSERT, все-или-ничего; upsert на пакете ставится на билк-билдер, а не на отдельные билдеры.
- Связи при создании — AddGroups/AddGroupIDs: ent сам откроет транзакцию и покатит узлы и join-строки вместе.

### Путь одной вставки

Соберите жизненный цикл client.User.Create()...AddGroups(...).Save(ctx) от конструктора до возврата.

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

1. `client.User.Create() — новый билдер, внутри UserMutation в режиме OpCreate`
2. `SetName, SetEmail, AddGroups — поля и рёбра копятся в мутации, порядок сеттеров свободный`
3. `Save(ctx): defaults() подставляет значения по умолчанию — created_at из time.Now`
4. `check(): обязательные поля и валидаторы схемы проверены, SQL ещё не было`
5. `sqlgraph: авто-транзакция, INSERT в users, затем строки в group_users`
6. `декод строки в *ent.User с ID; на ошибке — ValidationError, ConstraintError или context.Canceled`

### Соберите upsert и пакет

Впишите цель конфликта, вариант «обнови из вставки», пакетный конструктор из среза и проверку именно на дубль.

```go
// upsert: нет — вставь, есть — обнови (PostgreSQL):
err := client.User.
        Create().
        SetEmail("anna@entgo.io").
        SetName("anna").
        OnConflict___(user.FieldEmail). // цель конфликта
        Update___().                    // значения из вставки
        Exec(ctx)

// пакет из среза имён:
users, err := client.User.___(names, func(c *ent.UserCreate, i int) {
        c.SetName(names[i])
}).Save(ctx)

// дубль именно уникального индекса:
if sqlgraph.Is___(err) {
        // email занят — вернём форме понятную ошибку
}
```

Подсказка: Первые два пропуска — половины имён уже показанных методов; третий конструирует билдеры из среза с индексом; четвёртый — хвост функции-классификатора из пакета sqlgraph.

Правильные ответы в пропусках: `Columns`, `NewValues`, `MapCreateBulk`, `UniqueConstraintError`

> **Как проверять себя в этом уроке.** Базы у браузерной песочницы курса нет, поэтому runner-ов здесь не будет и код не запускается — это честное правило всего ent-курса: слою данных нужен настоящий драйвер. Зато вся интерактивность живёт прямо на странице: квизы и predict-задачи проверяют порядок этапов и диалектные ловушки, fill собирает upsert-цепочку, order восстанавливает жизненный цикл вставки, карточки закрепляют термины. Лучшая проверка всё равно локальная: откройте свой проект из домашек, включите флаг sql/upsert и прогоните каждую форму из этого урока на sqlite3 — файловая база поднимается одной строкой ent.Open.

### Что дальше

Писать мы научились — но вставленные строки нужно доставать обратно. Девятый урок — 09-zaprosy «Запросы: Query builder»: client.User.Query(), предикаты из ent/user, All, Only, First и Get по идентификатору — тот словарь, который мы уже щупали в самом первом уроке на примере NameEQ. Сегодняшний арсенал вам там пригодится сразу: Only и First бросают ошибки по своим правилам, и вы уже умеете читать классификацию ent. Полный план из 25 уроков — на [странице курса](/courses/ent/). Домашнее задание переводит формы из урока в пальцевую память — пройдите его на своей базе.

### Карточки: создание сущностей

- **client.User.Create() — что возвращает** — *UserCreate — билдер с UserMutation (OpCreate) внутри. Аргументов нет: ни ctx, ни структуры. Контекст передаётся в Save(ctx)/SaveX/Exec/ExecX. Билдер можно собрать заранее, дополнить в функции и исполнить позже.
- **Save, SaveX, Exec, ExecX** — Save(ctx) → (*User, error) — основной контракт. SaveX паникует вместо ошибки (запуск, тесты). Exec(ctx) → error — когда сущность не нужна. ExecX — Exec с паникой.
- **SetNillable и Nillable-поля** — SetNillableWebsite(*string) ставит поле, только если не nil («avoid nil checks»); nil — сеттер молча пропущен, Optional-колонка будет NULL. Генерируется для Optional и Default-полей. У Nillable-полей (bio) отдельного SetNillable нет — SetBio уже принимает *string.
- **CreateBulk и MapCreateBulk** — CreateBulk(builders ...*UserCreate) *UserCreateBulk → Save → ([]*User, error). MapCreateBulk(slice, func(c *UserCreate, i int)) строит билдеры из среза. Один INSERT с несколькими VALUES: разные поля выравниваются NULL, ошибка одной строки валит пакет, ID либо у всех, либо ни у кого.
- **OnConflict: формы upsert** — OnConflict()/OnConflictColumns(user.FieldEmail) → *UserUpsertOne. UpdateNewValues() — mutable-поля из вставки (Immutable исключены). Update(func(u *ent.UserUpsert)) — свой SET: SetX/UpdateX/AddX/ClearX. Ignore() и DoNothing() (SQLite/PG). ID(ctx) — id вставленной или обновлённой; MySQL с нечисловым ID — только Exec. Пустой OnConflict() на PostgreSQL упадёт: цель конфликта обязательна. Upsert — один стейтмент: срабатывают только create-хуки.
- **Ошибки создания** — ValidationError — до базы: «missing required field "User.age"», имя поля в ve.Name. ConstraintError — обёртка «ent: constraint failed: ...» с Unwrap; ловится через ent.IsConstraintError. sqlgraph.IsUniqueConstraintError — только дубли: «Error 1062» (MySQL), «violates unique constraint» (PG), «UNIQUE constraint failed» (SQLite). Отмена контекста — errors.Is(err, context.Canceled), ent её не заворачивает.

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

- client.User.Create() не принимает ни контекста, ни готовой структуры — он возвращает *UserCreate, билдер с UserMutation (OpCreate) внутри. Контекст передаётся на исполнении: Save(ctx), SaveX, Exec, ExecX. Формы Create(ctx, *User) в ent нет — поля перекладываются сеттерами, и компилятор проверяет каждое.
- Четыре метода исполнения — два контракта: Save → (*User, error) и Exec → error обрабатывают ошибку; SaveX и ExecX паникуют и уместны в запуске, тестах и CLI, но не в HTTP-хендлерах.
- Порядок этапов Save: defaults() (created_at из time.Now) → хуки → check() — обязательные поля и валидаторы до всякого SQL → sqlgraph: INSERT. Ошибка валидации — ValidationError с именем поля.
- SetNillable-сеттеры («avoid nil checks») генерируются для Optional и Default-полей: SetNillableWebsite(*string) при nil молча пропускает поле. У Nillable-полей отдельный SetNillable не генерируется — там SetX уже принимает указатель.
- Связи при создании: AddGroups/AddGroupIDs на обратной половине M2M (канон AddUsers из шестого урока — с другой стороны). Внешние рёбра включают авто-транзакцию: узел и join-строки катятся вместе, откат — на любой ошибке.
- CreateBulk(builders...) и MapCreateBulk(slice, fn) → ([]*User, error): один INSERT с несколькими VALUES, выравнивание колонок NULL-ами, все-или-ничего, «inconsistent id values», если ID задан не у всех билдеров.
- OnConflict()/OnConflictColumns(...) переводит цепочку в *UserUpsertOne: UpdateNewValues() (Immutable исключены), Update(func(u *ent.UserUpsert)) — SetX/UpdateX/AddX/ClearX, Ignore(), DoNothing() (SQLite/PG), ID(ctx) — id вставленной или обновлённой строки. PostgreSQL требует цель конфликта; MySQL — ON DUPLICATE KEY с LAST_INSERT_ID-трюком. Upsert — один стейтмент: только create-хуки.
- Дубли: sqlgraph.IsUniqueConstraintError матчит тексты драйверов («Error 1062», «violates unique constraint», «UNIQUE constraint failed»), сгенерированный код оборачивает констрейнт-сбои в ent.ConstraintError («ent: constraint failed: ...», Unwrap сохраняет оригинал). Отмена контекста проходит насквозь: errors.Is(err, context.Canceled).

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

Пройдите все формы урока на своей базе из седьмого урока (флаг sql/upsert включён). Первое: создайте anna сеттер-стилем, затем повторите вызов с тем же email и разберите текст ошибки — найдите в нём имя индекса, прогоните ошибку через ent.IsConstraintError и sqlgraph.IsUniqueConstraintError и убедитесь, что вернули их по-разному. Второе: соберите трёх пользователей через MapCreateBulk из среза имён и уберите один из email-ов на дубль — проверьте, что ни одна строка не вставлена. Третье: выполните upsert-цепочку с UpdateNewValues на существующем email — сверьте, что name обновился, а created_at остался прежним (Immutable-поле исключено). Четвёртое: на PostgreSQL уберите цель конфликта из OnConflictColumns и получите родную ошибку про matching constraint. Пятое: создайте пользователя с AddGroupIDs сразу в двух группах и найдите в логах базы одну транзакцию с INSERT в users и двумя в group_users. Вопрос на подумать: почему upsert исполняется одним стейтментом и какие хуки при нём сработают, а какие — нет?

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

### Почему client.User.Create() не принимает контекст, если почти весь остальной API устроен наоборот?

Потому что Create() ничего не исполняет: он лишь возвращает билдер с мутацией внутри. Контекст нужен на исполнении — Save(ctx), Exec(ctx) или ID(ctx). Такое разделение даёт свободу: билдер можно собрать заранее, дополнить чужой функцией и исполнить позже, в другом контексте. Это же объясняет, почему у обычного create-билдера нет ID(ctx): вопрос «какой id вернулся» осмыслен и для обновлённой строки — он появляется только после OnConflict(), вместе с другим типом билдера.

### Почему OnConflict() без колонок работает на MySQL, но падает на PostgreSQL?

ent рендерит upsert по-разному: для MySQL это ON DUPLICATE KEY UPDATE — конструкция без цели конфликта по определению; для PostgreSQL и SQLite — ON CONFLICT, где для DO UPDATE цель обязательна. Документация фиксирует: «In PostgreSQL, the conflict target is required». Решение — OnConflictColumns(user.FieldEmail) или SQL-API: sql.ConflictColumns(...) для колонок и sql.ConflictConstraint(...) для именованного констрейнта. Для DO NOTHING цель не нужна, но MySQL всё равно не умеет — ent подменяет его Ignore-вариантом.

### Как надёжно отличить «email занят» от другой ошибки базы?

Двумя слоями. Сначала ent.IsConstraintError(err) — ошибки на errors.As: она поймает обёртку ent.ConstraintError, в которую сгенерированный код заворачивает любые нарушения констрейнтов, включая уникальность и внешние ключи. Затем sqlgraph.IsUniqueConstraintError(err) — она проверяет текст ошибки на маркеры конкретных диалектов: «Error 1062» у MySQL, «violates unique constraint» у PostgreSQL, «UNIQUE constraint failed» у SQLite. Исходный текст драйвера не теряется — он внутри ConstraintError через Unwrap, поэтому в лог попадает и имя индекса, и значение дубликата.
