Sửa lỗi 'duplicate key value violates unique constraint' trong PostgreSQL

intermediate🐘 PostgreSQL2026-03-29| PostgreSQL 12+, Linux/macOS/Windows, mọi client (psql, pgAdmin, code ứng dụng)

Error Message

ERROR: duplicate key value violates unique constraint
#postgresql#unique#constraint#primary-key

Chuyện gì đang xảy ra

Bạn insert hoặc update một hàng và PostgreSQL báo lỗi:

ERROR: duplicate key value violates unique constraint "users_pkey"
DETAIL: Key (id)=(42) already exists.

Đọc dòng DETAIL trước — nó chỉ chính xác cột nào (id) và giá trị nào (42) gây ra xung đột. Như vậy là 90% việc debug đã xong. Câu hỏi còn lại là tại sao giá trị đó đã tồn tại.

Các nguyên nhân phổ biến

  • Sequence của SERIAL hoặc BIGSERIAL bị lệch so với dữ liệu thực trong bảng — rất hay xảy ra sau khi import hàng loạt hoặc pg_restore
  • Bạn tự chỉ định ID đã tồn tại trong bảng
  • Một cột unique (email, username, slug) nhận giá trị trùng lặp
  • Hai request đồng thời cùng insert một hàng giống nhau

Nguyên nhân 1: Sequence bị lệch (phổ biến nhất)

Hình dung thế này: bạn vừa chạy pg_restore trên cơ sở dữ liệu có 50.000 người dùng. Các hàng đó được import kèm ID tường minh, nên sequence của PostgreSQL chưa bao giờ tăng lên. Nó vẫn nghĩ ID tiếp theo là 1 — trong khi bảng đã có dữ liệu đến tận 50000. Mọi lần insert mới đều bị lỗi ngay lập tức.

Kiểm tra giá trị sequence so với ID max thực tế

-- Sequence sẽ sinh ra giá trị nào tiếp theo?
SELECT last_value FROM users_id_seq;

-- ID cao nhất hiện có trong bảng là bao nhiêu?
SELECT MAX(id) FROM users;

Nếu last_value thấp hơn MAX(id), đó chính là vấn đề.

Cách sửa: reset sequence

-- Reset về max(id) để lần insert tiếp theo lấy max(id) + 1
SELECT setval('users_id_seq', (SELECT MAX(id) FROM users));

Không muốn hardcode tên sequence? Dùng pg_get_serial_sequence thay thế:

SELECT setval(
  pg_get_serial_sequence('users', 'id'),
  (SELECT MAX(id) FROM users)
);

Lần INSERT tiếp theo không chỉ định id sẽ nhận MAX(id) + 1 — gọn gàng và an toàn.

Sửa tất cả sequence bị lệch cùng lúc

Sau khi restore cả một cơ sở dữ liệu, hàng chục bảng có thể bị ảnh hưởng cùng lúc. Chạy script này một lần để reset toàn bộ sequence khóa chính trong một lượt:

DO $$
DECLARE
  r RECORD;
BEGIN
  FOR r IN
    SELECT
      tc.table_name,
      kc.column_name,
      pg_get_serial_sequence(tc.table_name, kc.column_name) AS seq
    FROM information_schema.table_constraints tc
    JOIN information_schema.key_column_usage kc
      ON tc.constraint_name = kc.constraint_name
    WHERE tc.constraint_type = 'PRIMARY KEY'
      AND pg_get_serial_sequence(tc.table_name, kc.column_name) IS NOT NULL
  LOOP
    EXECUTE format(
      'SELECT setval(%L, COALESCE(MAX(%I), 1)) FROM %I',
      r.seq, r.column_name, r.table_name
    );
  END LOOP;
END;
$$;

Nguyên nhân 2: Giá trị trùng trong cột unique

Constraint không phải lúc nào cũng trên khóa chính. Trường hợp này nhắm vào cột email:

ERROR: duplicate key value violates unique constraint "users_email_key"
DETAIL: Key (email)=(user@example.com) already exists.

Tìm hàng bị xung đột

SELECT * FROM users WHERE email = 'user@example.com';

Phương án A: Bỏ qua bản trùng lặp

INSERT INTO users (email, name)
VALUES ('user@example.com', 'Alice')
ON CONFLICT (email) DO NOTHING;

Phương án B: Upsert — cập nhật hàng đã có thay vì insert mới

INSERT INTO users (email, name, updated_at)
VALUES ('user@example.com', 'Alice Updated', NOW())
ON CONFLICT (email)
DO UPDATE SET
  name = EXCLUDED.name,
  updated_at = EXCLUDED.updated_at;

EXCLUDED là bảng ảo chứa hàng bị lỗi insert. Tham chiếu nó để lấy các giá trị mới bạn muốn ghi vào.

Nguyên nhân 3: Xung đột ID tường minh khi insert hàng loạt

Đang migrate dữ liệu từ hệ thống khác? Nếu các hàng nguồn mang ID riêng và một số ID đó đã tồn tại trong bảng đích, mọi hàng xung đột sẽ bị lỗi.

Phát hiện xung đột trước khi insert

-- Xem những ID nào sắp import đã tồn tại rồi
SELECT s.id
FROM staging_users s
INNER JOIN users u ON s.id = u.id;

Chỉ insert các hàng không xung đột

INSERT INTO users (id, email, name)
SELECT id, email, name FROM staging_users
ON CONFLICT (id) DO NOTHING;

Nguyên nhân 4: Race condition khi insert đồng thời

Hai API request đến cách nhau vài mili-giây. Cả hai đều kiểm tra cơ sở dữ liệu, cả hai đều thấy chưa có hàng nào, và cả hai cùng thực hiện insert. Một cái thắng. Cái kia vướng constraint.

Kiểm tra ở tầng ứng dụng không thể ngăn điều này — khoảng trống giữa lúc kiểm tra và lúc insert đủ để request thứ hai len vào. Hãy xử lý ở tầng cơ sở dữ liệu:

# Python (psycopg2) — bắt lỗi và coi như "đã tồn tại"
try:
    cursor.execute(
        "INSERT INTO users (email) VALUES (%s)",
        (email,)
    )
    conn.commit()
except psycopg2.errors.UniqueViolation:
    conn.rollback()
    cursor.execute("SELECT * FROM users WHERE email = %s", (email,))

Tốt hơn nữa, đẩy xử lý xung đột vào chính câu SQL để lệnh insert idempotent ngay từ đầu:

INSERT INTO users (email)
VALUES ('user@example.com')
ON CONFLICT (email) DO NOTHING
RETURNING id;

Xác nhận cách sửa đã có tác dụng

-- Kiểm tra sequence đã vượt qua max hiện tại chưa
SELECT
  last_value AS sequence_next,
  (SELECT MAX(id) FROM users) AS table_max
FROM users_id_seq;

-- Thử insert bình thường — không chỉ định id, phải thành công
INSERT INTO users (email, name) VALUES ('test@example.com', 'Test');
SELECT * FROM users WHERE email = 'test@example.com';

Những điểm cần ghi nhớ

  • Sau pg_restore hoặc bất kỳ lần import hàng loạt nào có ID tường minh — reset sequence trước khi ứng dụng đi vào hoạt động. Thêm bước này vào runbook restore để không bao giờ bỏ sót.
  • ON CONFLICT luôn tốt hơn kiểm tra trùng lặp ở tầng ứng dụng. Nó chạy nguyên tử bên trong cơ sở dữ liệu; code ứng dụng không thể đảm bảo điều đó.
  • Dòng DETAIL là công cụ debug nhanh nhất. Nó chỉ rõ tên constraint và giá trị xung đột — bắt đầu từ đó, không phải từ stack trace.
  • Cân nhắc chuyển sang dùng GENERATED ALWAYS AS IDENTITY (PostgreSQL 10+) thay cho SERIAL. PostgreSQL quản lý identity column chặt chẽ hơn, khiến việc sequence bị lệch khó xảy ra hơn nhiều.

Related Error Notes