Upserts: ON DUPLICATE KEY UPDATE, REPLACE and INSERT IGNORE
Three ways MySQL handles an insert that hits a unique key, what each one really does to the row, and the affected-rows and auto-increment side effects interviewers ask about.
After this lesson you can
- Write INSERT ... ON DUPLICATE KEY UPDATE with a row alias
- Explain why REPLACE is a delete followed by an insert
- Say what INSERT IGNORE ignores besides duplicates
- Read the affected-rows count of an upsert
An upsert inserts a row, or updates it if it already exists. In MySQL,
"already exists" always means: the new row would violate a PRIMARY KEY
or UNIQUE index. That makes the unique key the real business rule. If
"one counter per page" is not enforced by a unique index on page, no
upsert will enforce it for you.
Why not SELECT first and then choose between INSERT and UPDATE?
Because two sessions can run that SELECT at the same moment, both see no
row, and both insert: without a unique key you get two rows, with one the
second session fails with a duplicate-key error. A single upsert statement
leaves the decision to the unique index, inside one statement, so there
is no gap between the check and the write for another session to use.
INSERT ... ON DUPLICATE KEY UPDATE
INSERT INTO tags (name, hits) VALUES ('mysql', 1) AS new
ON DUPLICATE KEY UPDATE hits = tags.hits + new.hits;
The row alias (AS new, MySQL 8.0.19 and later) names the row you tried
to insert; tags.hits is the value already stored. The older spelling
VALUES(hits) still works but is deprecated: the example's third
statement uses it and gets warning 1287.
CREATE TABLE tags ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(20) NOT NULL, hits INT NOT NULL, UNIQUE KEY uq_name (name));INSERT INTO tags (name, hits) VALUES ('mysql', 1) AS newON DUPLICATE KEY UPDATE hits = tags.hits + new.hits;SET @first = ROW_COUNT(); INSERT INTO tags (name, hits) VALUES ('mysql', 1) AS newON DUPLICATE KEY UPDATE hits = tags.hits + new.hits;SET @second = ROW_COUNT(); -- The deprecated VALUES() spelling: it works, with a warning.INSERT INTO tags (name, hits) VALUES ('sql', 1)ON DUPLICATE KEY UPDATE hits = hits + VALUES(hits);GET DIAGNOSTICS CONDITION 1 @old_spelling_warning = MYSQL_ERRNO; -- An update that leaves the row exactly as it was.INSERT INTO tags (name, hits) VALUES ('mysql', 1) AS newON DUPLICATE KEY UPDATE hits = tags.hits;SET @unchanged = ROW_COUNT(); SELECT @first AS affected_on_insert, @second AS affected_on_update, @unchanged AS affected_unchanged, @old_spelling_warning AS values_fn_warning, GROUP_CONCAT(CONCAT(id, ':', name, ':', hits) ORDER BY id) AS id_name_hitsFROM tags;Things to notice:
- Affected rows is 1 when a row was inserted and 2 when an existing
row was updated. When the update leaves the row exactly as it was, the
server reports 0, or 1 if the client connected with the
CLIENT_FOUND_ROWSflag. This sandbox's driver sets that flag, which is why the last upsert reports 1; check what your own driver does before branching on the number. - The auto-increment counter moves anyway. InnoDB reserves an id
before it knows the insert will become an update, so the second tag got
id 3, not 2. Gaps in an
AUTO_INCREMENTcolumn are normal and must never be relied on to count rows.
The update runs the same way for an INSERT ... SELECT, so a batch with
the same key twice adds both quantities, which the exercise relies on.
REPLACE is delete-then-insert
REPLACE INTO looks like an upsert and is not. When the new row collides,
MySQL deletes the old row and inserts the new one.
CREATE TABLE users ( id INT PRIMARY KEY, email VARCHAR(50) NOT NULL UNIQUE, name VARCHAR(20), created DATE NOT NULL DEFAULT '2026-01-01');CREATE TABLE orders ( id INT PRIMARY KEY, user_id INT, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE);INSERT INTO users VALUES (1, 'ann@x.io', 'Ann', '2025-05-05');INSERT INTO orders VALUES (100, 1), (101, 1);REPLACE INTO users (id, email, name) VALUES (1, 'ann@x.io', 'Ann B');SET @affected = ROW_COUNT(); SELECT @affected AS affected, (SELECT CONCAT(name, ' / ', created) FROM users WHERE id = 1) AS user_now, (SELECT COUNT(*) FROM orders) AS orders_left;Affected rows is 2 (one delete, one insert). Every column you did not
supply was reset to its default, so the original created date is gone.
And because it was a real delete, ON DELETE CASCADE removed both of the
user's orders. Use ON DUPLICATE KEY UPDATE unless you truly mean "throw
the old row away".
INSERT IGNORE ignores more than duplicates
INSERT IGNORE skips rows that would duplicate a key, which is sometimes
what you want. It also downgrades other errors to warnings, including
the strict-mode errors from the data-types lesson.
CREATE TABLE codes (id INT PRIMARY KEY, code VARCHAR(3) NOT NULL UNIQUE, n TINYINT);INSERT INTO codes VALUES (1, 'abc', 1);INSERT IGNORE INTO codes VALUES (1, 'xyz', 5), -- duplicate id (2, 'abc', 6), -- duplicate code (3, 'toolong', 7), -- too long for VARCHAR(3) (4, 'ok', 300); -- out of range for TINYINTSET @warnings = @@warning_count; SELECT @warnings AS warnings, id, code, nFROM codesORDER BY id;Two rows were skipped as duplicates, but 'toolong' was stored as 'too'
and 300 as 127, with four warnings and no error. If you only mean
"skip duplicates", ON DUPLICATE KEY UPDATE id = id does that and still
fails on bad data.
Common mistakes
- Upserting without a unique index on the natural key. Every attempt inserts a new row.
- Using
REPLACEas an upsert. It resets unspecified columns and fires delete cascades, as the orders count of 0 shows. - Using
INSERT IGNOREto skip duplicates. It also accepts truncated and clamped values silently. - Expecting
AUTO_INCREMENTto be gap-free. The upsert consumed id 2.
What interviewers ask
- How do you upsert in MySQL?
INSERT ... ON DUPLICATE KEY UPDATE, keyed on aPRIMARY KEYorUNIQUEindex, using a row alias for the new values. - What is the difference between
REPLACEandON DUPLICATE KEY UPDATE?REPLACEdeletes and re-inserts (defaults reset, cascades fire); the other updates the existing row in place.
Exercise · Pro
Apply a delivery batch to inventory
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade