---
title: "Ошибка 23505: конфликты уникальности"
description: "Ошибка 23505 unique violation в pgx: on conflict do nothing и do update с EXCLUDED, upsert через RETURNING, код 409 и ConstraintName в API. Примеры на Go."
course: "PostgreSQL в Go: драйвер pgx с нуля до продакшена"
url: "https://golangmaster.ru/courses/pgx/lessons/17-unikalnost-23505/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Ошибка 23505: конфликты уникальности

*Курс «PostgreSQL в Go: драйвер pgx с нуля до продакшена», урок 17 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/pgx/lessons/17-unikalnost-23505/*

Ошибка 23505 unique violation — самая частая ошибка записи в Postgres: дубликат по первичному ключу или уникальному индексу. Разберём on conflict в двух режимах, upsert с псевдотаблицей EXCLUDED, превращение кода 23505 в дружелюбный 409 и ловушку проверки существования через SELECT перед INSERT.

Пользователь нажимает «зарегистрироваться» второй раз, воркер перезапускается и обрабатывает ту же задачу, два запроса приходят одновременно — и база отвечает SQLSTATE 23505, unique violation. В шестнадцатом уроке этот код был одним из многих; здесь он главный, потому что у него два пути решения, и выбор между ними определяет архитектуру. Путь первый — ловить ошибку: INSERT падает, код раскладывается через errors.As, Detail честно показывает конфликтное значение вида Key (email)=(a@b.example) already exists., а ConstraintName называет нарушенный констрейнт. Путь второй — предупреждать: конструкция on conflict в самом INSERT говорит серверу, что делать при встрече с дубликатом, и гонка исчезает, потому что проверка и вставка сливаются в одну атомарную команду. Оба пути рабочие и часто живут в одном сервисе: upsert для счётчиков и синхронизаций, ловля кода для форм регистрации. Источник кода тоже стоит проговорить: 23505 прилетает не только от PRIMARY KEY и UNIQUE-констрейнтов, но и от любых уникальных индексов, включая частичные и индексы-выражения — серверу всё равно, как именно вы попросили уникальность. Один и тот же INSERT может поймать её и в голом Exec из пятого урока, и внутри транзакции из тринадцатого, и в Batch из восемнадцатого — механика распаковки не меняется. Разберём механику on conflict, псевдотаблицу EXCLUDED, возврат данных через RETURNING и правила превращения 23505 в понятный ответ API.

### ON CONFLICT DO NOTHING: атомарный пропуск

Конструкция вешается на INSERT: INSERT INTO users (email, name) VALUES ($1, $2) ON CONFLICT (email) DO NOTHING. Скобки после ON CONFLICT — цель конфликта: колонка или список колонок, по которым существует уникальный индекс. При встрече с дубликатом сервер молча пропускает строку, ошибки нет, RowsAffected в CommandTag равен нулю — так отличают вставку от пропуска без второго запроса. Механика экономит и код, и время: не нужен SELECT на проверку, не нужна транзакция на два запроса, не нужен ретрай — один Exec из пятого урока закрывает сценарий повторной отправки формы, повторной доставки задачи в очередь и перезапуска воркера на середине списка. Цель указывать необязательно: голое DO NOTHING глушит любой конфликт уникальности, и это удобно в скриптах и опасно в продакшене — новый констрейнт, о котором вы забыли, тоже будет заглушен, и баг осядет в данных без единой ошибки в логе. Существует и вариант по имени ограничения: ON CONFLICT ON CONSTRAINT users_email_key — имя берётся из Detail ошибки или из миграции. Главное свойство конструкции — атомарность: не существует окна между проверкой и вставкой, два параллельных запроса с одинаковым email дадут один INSERT и один пропуск, без гонки и без падений. Любой SELECT перед INSERT такой гарантии не даёт — об этом отдельное живое место ниже.

### Upsert: DO UPDATE с EXCLUDED и RETURNING

```go
var id int64
var inserted bool

err := conn.QueryRow(ctx, `
        INSERT INTO product_stats (sku, views)
        VALUES ($1, 1)
        ON CONFLICT (sku) DO UPDATE
        SET views = product_stats.views + 1
        RETURNING id, (xmax = 0) AS inserted`,
        sku).Scan(&id, &inserted)
if err != nil {
        log.Fatal(err)
}
```

Одна команда делает и вставку, и обновление: новая строка получает views 1, существующая — инкремент. RETURNING приносит id в обоих случаях, а сравнение xmax с нулём — трюк различения: строка только что вставлена или обновлена. Курсор QueryRow сам закроется после Scan, ошибки конфликта не будет вовсе.

### DO UPDATE и EXCLUDED: upsert без гонки

Режим DO UPDATE превращает INSERT в upsert: при конфликте вместо вставки выполняется UPDATE конфликтующей строки. Псевдотаблица EXCLUDED хранит предложенную к вставке строку — значения, которые вы передали аргументами: SET views = EXCLUDED.views + product_stats.views складывает новое значение со старым, SET name = EXCLUDED.name перезаписывает. Квалификация обязательна: без префикса имени таблицы поле views в правой части неоднозначно, и сервер ответит ошибкой про ambiguous column. У DO UPDATE есть необязательное WHERE: условие не сошлось — строка не обновится, RowsAffected будет нулём, RETURNING её не вернёт, и это штатная ситуация, а не ошибка. Апсерт атомарен и при конкурентности: два параллельных upsert одной строки выстроятся в очередь на блокировку строки, второй дождётся и выполнится поверх первого; потерянных обновлений нет. Опасность обратного рода: два upsert, которые в одном запросе трогают строку A, а в другом — строку B и vice versa, могут встретиться лбами и получить 40P01 deadlock — класс 40 из шестнадцатого урока, лечится единым порядком строк во всех апсертах и ретраем транзакции. Справедливости ради: с пятнадцатой версии у Postgres есть и отдельная команда MERGE, решающая смежные задачи условного слияния; pgx ей не мешает — это просто SQL-текст в том же Exec, и вся анатомия ошибок отсюда переносится на неё без изменений.

### Проверка: DO NOTHING

**Вопрос:** INSERT с ON CONFLICT (email) DO NOTHING встретил дубликат. Что вернёт tag.RowsAffected()?

- 1 — строка всё равно считалась вставленной
- ✅ 0 — и ошибки при этом нет
- -1 — сигнал конфликта
- Exec вернул ошибку 23505

DO NOTHING не считается ошибкой: CommandTag покажет ноль затронутых строк. Именно по нулевому RowsAffected код различает вставку и пропуск. Без ON CONFLICT тот же INSERT вернул бы ошибку 23505, которую пришлось бы ловить через errors.As.

### Дружелюбный API: 23505 превращается в 409

Там, где on conflict не подходит — например, конфликт сообщает пользователю о занятом email, — код 23505 ловится и переводится на язык клиента. Схема из шестнадцатого урока: errors.As распаковывает PgError, ветка по pgerrcode.UniqueViolation, дальше — маппинг ConstraintName на понятный ответ: users_email_key значит «email занят», поле формы подсвечивается, HTTP-статус 409 Conflict. Имя констрейнта хардкодить можно, но осторожно: переименование в миграции сломает маппинг молча, поэтому реестр имён держите рядом с миграциями или сверяйте тестом на реальной базе. Пользователю Detail не отдаётся: строка Key (email)=(a@b.example) already exists. содержит значение, которое прилетело от клиента, — отражать ввод обратно в ответе значит кормить XSS и утечки. Правило распространяется на всю структуру: Message содержит имена таблиц, Hint — серверные советы, и всё это внутренняя кухня, которую клиенту знать не нужно. Формулировка своя, поле своё, код статуса по семантике: 409 для конфликта ресурса, 422 для проблем валидации. Один нюанс: на один INSERT с двумя уникальными индексами могут падать два разных 23505 — различать их по ConstraintName, а не только по коду, иначе занятый email и занятый телефон получат одинаковый ответ.

### Обработчик регистрации с 409

```go
func register(ctx context.Context, conn *pgxpool.Pool,
        email, name string) error {

        _, err := conn.Exec(ctx,
                "INSERT INTO users (email, name) VALUES ($1, $2)",
                email, name)
        var pgErr *pgconn.PgError
        if errors.As(err, &pgErr) &&
                pgErr.Code == pgerrcode.UniqueViolation {
                switch pgErr.ConstraintName {
                case "users_email_key":
                        return ErrEmailTaken
                case "users_phone_key":
                        return ErrPhoneTaken
                }
        }
        return err
}
```

Код проверяется первым, имя констрейнта — вторым: так один код 23505 раскладывается на разные бизнес-ситуации вместо одного обезличенного ответа. Каждая ветка возвращает доменную ошибку, которую HTTP-слой переводит в 409 Conflict с подсветкой нужного поля формы. Нераспознанные констрейнты падают в общий возврат наверх: новая миграция с новым уникальным индексом не пройдёт незамеченной в код-ревью.

### Что вернёт Scan?

```go
// В таблице уже есть строка sku = "A-1", id = 7, views = 5

var id int64
err := conn.QueryRow(ctx, `
    INSERT INTO product_stats (sku, views) VALUES ($1, 1)
    ON CONFLICT (sku) DO UPDATE
    SET views = product_stats.views + 1
    RETURNING id`, "A-1").Scan(&id)

fmt.Println(id, err)
```

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

- ✅ 7 <nil> — вернулся id обновлённой строки
- 0 ErrNoRows — строка не вставилась
- 0 <nil> — идентификатор при upsert не возвращается
- ошибка 23505 — конфликт всё равно падает

DO UPDATE выполняет UPDATE конфликтующей строки, и RETURNING возвращает её данные: id 7, views станет 6. Ошибки нет — конфликт разрешён самой командой. ErrNoRows был бы у голого INSERT без конфликта только при пустом RETURNING.

### Три способа обращения с дубликатом

| Способ | Что происходит | Когда выбирать | Цена ошибки |
| --- | --- | --- | --- |
| Голый INSERT + ловля 23505 | ошибка падает, код решает | регистрация, уникальный email, нужен ответ клиенту | лишняя поездка на сбой, но полный контроль |
| ON CONFLICT DO NOTHING | конфликт заглушается | идемпотентные воркеры, повторные задачи | ноль в RowsAffected как сигнал |
| ON CONFLICT DO UPDATE | конфликт обновляет строку | счётчики, кэши, синхронизация справочников | EXCLUDED и RETURNING дают данные за один рейс |

### Несколько констрейнтов и цена неверной цели

Цель в ON CONFLICT должна опираться на существующий уникальный индекс: напишете ON CONFLICT (email) там, где индекса по email нет, — сервер ответит 42P10 с текстом про отсутствующий matching constraint, и это ошибка разработки, а не данных. Частичный уникальный индекс — например, UNIQUE (email) WHERE deleted_at IS NULL — требует совпадающего WHERE в цели конфликта, иначе арбитр не найдётся. Таблица с двумя уникальными индексами создаёт главную развилку: DO NOTHING без цели заглушит оба, и телефонный дубликат пройдёт молча там, где вы ждали только почтовый. Правило: в бизнес-кодах цель пишется явно, безцельный DO NOTHING остаётся для служебных сценариев идемпотентности, где любой повтор допустим. Ловля кода 23505 при этом всегда различает констрейнты по ConstraintName — реестр из прошлого блока растёт вместе со схемой. И не забывайте про первичный ключ: upsert по id трогает PK-индекс так же, как email-констрейнт, — на одной таблице спокойно живут три цели конфликта для трёх разных сценариев. Составные цели тоже легальны: ON CONFLICT (tenant_id, slug) опирается на составной уникальный индекс целиком и часто правильнее цели по одной колонке, когда уникальность гарантируется только парой значений.

> **Живое место: SELECT перед INSERT.** Классическая гонка: сначала SELECT WHERE email = $1, потом INSERT. Проверка говорит «нет», между ней и вставкой вклинивается параллельный запрос того же клиента, оба INSERT уезжают — один падает с 23505, а если констрейнта нет вовсе, в базе дубликат, который потом ищут неделями. Если проверка существования раздельно с вставкой, то она всегда гонка на конкурентном клиенте. Рабочие замены: ON CONFLICT DO NOTHING или DO UPDATE — атомарны по построению; либо честная ловля 23505 после INSERT с повтором логики «уже есть». SELECT-первым оправдан только для UX-подсказок в форме, не для гарантий.

### Правила работы с 23505

- Гарантии даёт только атомарная команда: on conflict или ловля кода после INSERT
- Цель конфликта пишите явно — безцельный DO NOTHING глушит и будущие констрейнты
- DO UPDATE с EXCLUDED — для инкрементов и перезаписи, RETURNING — за данными без второго SELECT
- Разные констрейнты одного кода различаются по ConstraintName
- Detail не покидает бэкенда: в нём значение, присланное клиентом
- Нулевой RowsAffected у DO NOTHING — штатный сигнал «уже было»
- Перекрёстные апсерты двух строк — путь к 40P01: единый порядок и ретрай
- 42P10 — цель конфликта не совпала с индексом: сверьте миграцию

### Проверка: цель конфликта

**Вопрос:** На таблице есть уникальный индекс по (tenant_id, email). INSERT пишет ON CONFLICT (email) DO NOTHING. Что произойдёт?

- Сервер пропустит строку при конфликте
- Ошибка 23505 при дубликате — цель не опирается на существующий индекс
- ✅ Ошибка 42P10: подходящего уникального индекса для цели нет
- Конфликт разрешится по первому столбцу индекса

Цель ON CONFLICT должна совпасть с уникальным индексом по составу колонок; префикс составного индекса арбитром не служит. Сервер вернёт 42P10 invalid column reference. Правильно: ON CONFLICT (tenant_id, email) или ON CONFLICT ON CONSTRAINT с именем индекса.

### Соберите апсерт счётчика

Восстановите upsert: цель, ветка обновления, псевдотаблица с новым значением.

```go
INSERT INTO product_stats (sku, views)
VALUES ($1, 1)
ON CONFLICT (___) DO UPDATE
SET views = product_stats.views + ___.views
```

Подсказка: Цель — колонка уникального индекса, предложенная строка живёт в EXCLUDED.

Правильные ответы в пропусках: `sku`, `EXCLUDED`

### Соберите обработчик с 409

Расставьте строки: вставка, распаковка, ветка кода, доменная ошибка.

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

1. `_, err := pool.Exec(ctx, insertSQL, email, name)`
2. `if err != nil {`
3. `  var pgErr *pgconn.PgError`
4. `  if errors.As(err, &pgErr) {`
5. `    if pgErr.Code == pgerrcode.UniqueViolation {`
6. `      return ErrEmailTaken`
7. `    }`
8. `  }`
9. `  return err`
10. `}`
11. `return nil`

### Карточки: конфликты уникальности

- **23505** — unique_violation: дубликат по PK, UNIQUE-констрейнту или уникальному индексу. Класс 23 — нарушение целостности.
- **ON CONFLICT (col) DO NOTHING** — Атомарный пропуск дубликата: ошибки нет, RowsAffected 0. Цель — колонки существующего уникального индекса.
- **ON CONFLICT DO UPDATE** — Upsert: конфликтная строка обновляется. EXCLUDED хранит предложенную строку, RETURNING отдаёт её данные.
- **EXCLUDED** — Псевдотаблица со значениями, которые предлагались к вставке. Доступ только с квалификацией: EXCLUDED.views.
- **ConstraintName** — Поле PgError с именем нарушенного констрейнта — различает несколько уникальных индексов на одной таблице.
- **42P10** — Цель ON CONFLICT не совпала с уникальным индексом: опечатка в колонках или частичный индекс без WHERE в цели.

> **23505 в пачках и COPY.** Один конфликт валит всю команду: в pgx.Batch провал одной вставки уронит все результаты за ней — об этом восемнадцатый урок, в COPY FROM оборвётся всё копирование — девятнадцатый. Если поток данных заранее не вычищен от дубликатов, ставьте staging-таблицу: COPY без констрейнтов, затем INSERT ... SELECT ... ON CONFLICT из неё в целевую. Так массовая загрузка переживает дубликаты, а 23505 остаётся сигналом для точечных случаев. Если staging-путь кажется усложнением, то сравните его с ценой перезапуска загрузки на середине миллиона строк — выбор обычно очевиден.

### Конкурентность: апсерты выстраиваются в очередь

DO UPDATE берёт блокировку конфликтующей строки, и это следствие важно при нагрузке. Два горутины апсертят одну строку — вторая ждёт первую, задержка в миллисекунды, данные корректны. Десять тысяч горутин апсертят одну и ту же строку — очередь на блокировку станет бутылочным горлышком, и время ответа вырастет независимо от железа: для сверхгорячих счётчиков буферизуйте инкременты в приложении и сбрасывайте пачкой реже. Перекрёстный порядок — упомянутый deadlock — проверяется код-ревью: все транзакции, трогающие несколько строк upsert-ом, обязаны идти в одном порядке сортировки. WHERE в DO UPDATE добавляет ещё один слой: обновление только при устаревших данных, SET views = EXCLUDED.views WHERE product_stats.views < EXCLUDED.views, — конкурирующие записи перестают затирать свежие, а пропущенные строки честно показываются нулём RowsAffected. Числа для калибровки: очередь на блокировке стоит миллисекунды при десятках горутин, секунды — при тысячах, и граница, где буферизация становится обязательной, обычно наступает раньше, чем кажется при чтении кода. Всё это продолжение мысли шестнадцатого урока: серверная ошибка — не приговор, а переговоры с базой на языке кодов.

23505 перестал быть пугающим: теперь вы превращаете его в пропуск, апсерт или 409 по делу. Проверьте навык локально — честная оговорка: песочница не исполняет pgx, поэтому Postgres в Docker и два параллельных INSERT в небольшой main-программе — ваш полигон. Дальше по курсу: в [восемнадцатом](/courses/pgx/lessons/18-batch-zaprosy/) — pgx.Batch и сотни запросов за одну поездку; в [девятнадцатом](/courses/pgx/lessons/19-copy-iz-zapisi/) — COPY FROM для миллионов строк, где конфликты решаются staging-таблицей; в [шестнадцатом](/courses/pgx/lessons/16-oshibki-pgx/) — общая анатомия PgError, если поля структуры ещё не отложились. Домашнее задание сводит приёмы воедино: upsert с EXCLUDED, ловля 23505 по констрейнту и честная гонка из живого места на собственной руке. Оба следующих урока — Batch и COPY — продолжат тему: там 23505 перестаёт быть точечным сигналом и становится вопросом архитектуры массовой загрузки.

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

- 23505 — уникальный конфликт; Detail показывает значение, ConstraintName — имя констрейнта.
- ON CONFLICT (col) DO NOTHING атомарно пропускает дубликат: ошибки нет, RowsAffected 0.
- ON CONFLICT DO UPDATE строит upsert: EXCLUDED хранит предложенную строку, RETURNING возвращает результат.
- Цель конфликта должна опираться на уникальный индекс; частичный индекс требует WHERE в цели.
- Безцельный DO NOTHING глушит все конфликты — оставьте его идемпотентным воркерам.
- Разные констрейнты одного кода различаются по ConstraintName; наружу — 409 и своё поле формы.
- SELECT перед INSERT — гонка: гарантии даёт только атомарная команда.
- Апсерты горячей строки выстраиваются в очередь; перекрёстные — путь к 40P01.
- MERGE из PostgreSQL 15 решает смежные задачи; pgx исполняет его как обычный SQL-текст.

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

Сделайте таблицу product_stats с PK по sku и напишите upsert из урока: добейтесь и вставки, и инкремента одной командой, различите их по xmax. Добавьте второй уникальный индекс по phone и напишите реестр ConstraintName с доменными ошибками: два разных 23505 должны давать два разных ответа. Воспроизведите гонку из живого места: две горутины с SELECT-then-INSERT на одном email — и замените код на ON CONFLICT, чтобы дубликат исчез. Замерьте время обеих версий на тысяче повторных вставок — разница покажет цену лишнего SELECT в вашей сети. В конце проверьте 42P10: уберите индекс и посмотрите, как сервер откажется от цели конфликта.

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

### Как отличить вставку от обновления в upsert?

По трюку RETURNING id, (xmax = 0) AS inserted: xmax — служебная колонка версий строк, у только что вставленной строки она нулевая, у обновлённой — нет. Способ неофициальный, но используется широко и стабилен. Альтернатива — читать RowsAffected: и вставка, и DO UPDATE дают единицу, так что для различения он не годится.

### DO NOTHING или ловля 23505 — что выбрать?

DO NOTHING, когда дубликат — штатный повтор и реакция не нужна: идемпотентные воркеры, повторная доставка. Ловля кода — когда конфликт информативен: пользователю нужен 409 с полем, метрикам — событие повторной регистрации. Часто в одном сервисе оба: апсерт для внутренней синхронизации, ловля — для публичных форм. Рост доли нулевых RowsAffected у DO NOTHING — тот же сигнал в метриках, что и частота 23505 при ловле кода.

### Почему нельзя просто SELECT и вставить, если пусто?

Потому что между SELECT и INSERT приезжает параллельный запрос: оба увидят пустоту, оба вставят — один упадёт с 23505 или, без констрейнта, дубликат останется в базе навсегда. Окно гонки маленькое, но на нагрузке оно открывается регулярно. ON CONFLICT сливает проверку со вставкой в одну атомарную команду на уровне сервера. Помните и про вторую половину гонки: без уникального индекса база промолчит, и дубликат станет данными, а не ошибкой.
