SQLite와 PostgreSQL
SQLite와 PostgreSQL은 모두 ON CONFLICT를 쓰며 기본 문법도 같습니다. 한쪽에서 작성한 쿼리를 다른 쪽에서도 그대로 실행할 수 있습니다.
1
-- Insert, or update the row that is already there.
INSERT INTO wishlists (customer_id, product_id, added_at)
VALUES (1, 288, '2026-01-01 00:00:00')
ON CONFLICT (customer_id, product_id)
DO UPDATE SET added_at = excluded.added_at;
1행 변경
브라우저의 새 탭에서 Planche가 열리고, 샘플 데이터베이스가 로드된 채로 이 쿼리가 들어갑니다. 이 페이지의 다른 예제도 같은 탭을 다시 씁니다. F9를 누르기 전까지는 아무것도 실행되지 않습니다.
충돌을 판단할 컬럼을 지정하고, excluded로 넣으려던 행을 참조합니다. 두 번 실행하면 처음에는 INSERT, 두 번째에는 UPDATE가 일어납니다.
2
SELECT customer_id, product_id, added_at
FROM wishlists
WHERE customer_id = 1 AND product_id = 288;
결과 — 1행
| customer_id | product_id | added_at |
|---|---|---|
| 1 | 288 | 2026-01-01 00:00:00 |
다른 선택지는 충돌이 나면 기존 행을 그대로 두는 것입니다.
3
-- "Insert if it is not there, otherwise leave it alone." No error.
INSERT INTO wishlists (customer_id, product_id, added_at)
VALUES (1, 288, '1999-12-31 00:00:00')
ON CONFLICT (customer_id, product_id) DO NOTHING;
0행 변경
이 경우에는 데이터가 바뀌지 않고 오류도 나지 않습니다. 기본 키 충돌로 실패하는 일반 INSERT와의 차이입니다.
PostgreSQL
INSERT INTO wishlists (customer_id, product_id, added_at)
VALUES (1, 288, '2026-01-01 00:00:00')
ON CONFLICT (customer_id, product_id)
DO UPDATE SET added_at = EXCLUDED.added_at;
-- DO NOTHING is the same idea as SQLite's.
INSERT INTO wishlists (customer_id, product_id)
VALUES (1, 288)
ON CONFLICT DO NOTHING;
PostgreSQL — 비교용입니다. 브라우저 샘플 데이터베이스는 SQLite라서 이 문장은 여기서 실행할 수 없습니다.
PostgreSQL에서는 관례상 EXCLUDED를 대문자로 쓰지만 소문자도 사용할 수 있습니다. UPDATE 쪽에 WHERE를 붙여 특정 조건일 때만 갱신하도록 할 수도 있습니다.
MySQL과 MariaDB
MySQL / MariaDB
-- MySQL 8.0.20 and later: alias the new row instead of VALUES().
INSERT INTO wishlists (customer_id, product_id, added_at)
VALUES (1, 288, '2026-01-01 00:00:00') AS new
ON DUPLICATE KEY UPDATE added_at = new.added_at;
-- Older MySQL and MariaDB:
INSERT INTO wishlists (customer_id, product_id, added_at)
VALUES (1, 288, '2026-01-01 00:00:00')
ON DUPLICATE KEY UPDATE added_at = VALUES(added_at);
MySQL / MariaDB — 비교용입니다. 브라우저 샘플 데이터베이스는 SQLite라서 이 문장은 여기서 실행할 수 없습니다.
이름만 다른 문법은 아닙니다. 이 절은 지정한 제약이 아니라 어느 유니크 제약이든 위반되면 갱신합니다. 유니크 키가 여러 개인 테이블에서는 의도하지 않은 키 충돌로도 UPDATE가 일어날 수 있습니다. MySQL 8.0.20부터는 VALUES(col) 대신 새 행에 별칭을 붙이는 AS new 형태가 권장됩니다. MariaDB는 계속 VALUES()를 사용합니다.
SQL Server와 Oracle
SQL Server / Oracle
MERGE INTO wishlists AS target
USING (SELECT 1 AS customer_id, 288 AS product_id, '2026-01-01' AS added_at) AS source
ON target.customer_id = source.customer_id
AND target.product_id = source.product_id
WHEN MATCHED THEN
UPDATE SET added_at = source.added_at
WHEN NOT MATCHED THEN
INSERT (customer_id, product_id, added_at)
VALUES (source.customer_id, source.product_id, source.added_at);
SQL Server / Oracle — 비교용입니다. 브라우저 샘플 데이터베이스는 SQLite라서 이 문장은 여기서 실행할 수 없습니다.
MERGE는 SQL 표준 문법이지만 가장 길게 작성해야 합니다. 들어올 행을 쿼리로 만들고 대상과 조인한 뒤, 각 경우의 동작을 따로 적습니다. WHEN NOT MATCHED BY SOURCE THEN DELETE를 추가하면 전체 동기화도 할 수 있습니다. Oracle의 PL/SQL 블록에서는 끝 세미콜론이 필요 없고 SQL Server에서는 필요하므로, 쿼리를 옮길 때 이 차이도 확인하세요.
SQL Server에서 한 행만 처리할 때는 트랜잭션 안에서 UPDATE를 실행하고 @@ROWCOUNT를 확인한 뒤 INSERT하는 방식을 쓰기도 합니다. MERGE에는 동시성과 트리거 관련 이슈가 보고돼 왔기 때문입니다. MERGE를 사용한다면 적절한 격리 수준의 트랜잭션으로 감싸고, 문장 하나만으로 모든 상황이 안전하다고 가정하지 마세요.
REPLACE는 UPSERT가 아닙니다
SQLite와 MySQL의 REPLACE는 UPSERT를 짧게 쓴 문법처럼 보일 수 있지만, 동작은 다릅니다.
4
-- REPLACE deletes the conflicting row and inserts a new one, so
-- added_at is not kept — it falls back to its default. Run the check
-- above again afterwards and compare.
INSERT OR REPLACE INTO wishlists (customer_id, product_id)
VALUES (1, 288);
1행 변경
확인 쿼리를 다시 실행하면 added_at이 2026년 날짜가 아니라 현재 시각으로 바뀐 것을 볼 수 있습니다. REPLACE는 충돌한 행을 삭제하고 새 행을 넣습니다. 따라서 나열하지 않은 컬럼은 기본값으로 돌아가며, 삭제 트리거와 외래 키 연쇄 삭제도 발생할 수 있습니다.
기존 행을 삭제하고 새로 넣으려는 경우에만 REPLACE를 사용하세요. 일반적인 UPSERT에는 ON CONFLICT가 맞습니다.