Insert, update, delete, upsert

Reading rows is only half of SQL. This lesson changes data with INSERT, UPDATE, and DELETE, always using RETURNING so the result is visible.

Migration

One table, users. email is UNIQUE — that gives PostgreSQL a lookup index for free, and it's what an upsert will conflict on later in this lesson.

CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT NOT NULL UNIQUE,
  gender TEXT NOT NULL,
  active BOOLEAN NOT NULL DEFAULT true
);

Seed

Eight people, a mix of genders, everyone active to start.

INSERT INTO users (name, email, gender, active)
VALUES
  ('Ada Lovelace', 'ada@example.com', 'female', true),
  ('Grace Hopper', 'grace@example.com', 'female', true),
  ('Alan Turing', 'alan@example.com', 'male', true),
  ('Katherine Johnson', 'katherine@example.com', 'female', true),
  ('Barbara Liskov', 'barbara@example.com', 'female', true),
  ('Donald Knuth', 'donald@example.com', 'male', true),
  ('John McCarthy', 'john@example.com', 'male', true),
  ('Edsger Dijkstra', 'edsger@example.com', 'male', true);

Insert a row

INSERT adds a row. RETURNING shows the row PostgreSQL stored.

INSERT INTO users (name, email, gender)
VALUES ('Radia Perlman', 'radia@example.com', 'female')
RETURNING *;
idnameemailgenderactive
9Radia Perlmanradia@example.comfemaletrue

Update and delete a row

UPDATE changes columns on matching rows. DELETE removes matching rows outright. Both scope their effect with a WHERE clause — leave it off and every row in the table is changed, or gone.

Update a row

UPDATE changes matching rows. The WHERE clause keeps the change focused.

UPDATE users
SET active = false
WHERE email = 'alan@example.com'
RETURNING *;
idnameemailgenderactive
3Alan Turingalan@example.commalefalse

Delete a row

DELETE removes matching rows. RETURNING is useful when you want to inspect what was removed.

DELETE FROM users
WHERE email = 'grace@example.com'
RETURNING *;
idnameemailgenderactive
2Grace Hoppergrace@example.comfemaletrue

The cost of insert, update, delete

Let's take a look at each query's EXPLAIN.

Insert

Insert on users  (cost=0.00..0.01 rows=1 width=101)
  ->  Result  (cost=0.00..0.01 rows=1 width=101)

Update

Update on users  (cost=0.15..8.17 rows=1 width=7)
  ->  Index Scan using users_email_key on users  (cost=0.15..8.17 rows=1 width=7)
        Index Cond: (email = 'alan@example.com'::text)

Delete

Delete on users  (cost=0.15..8.17 rows=1 width=6)
  ->  Index Scan using users_email_key on users  (cost=0.15..8.17 rows=1 width=6)
        Index Cond: (email = 'grace@example.com'::text)

INSERT has nothing to find — it just builds the new row and stops. The plan is Insert on users straight over a Result node, cost close to 0.00..0.01.

UPDATE and DELETE have to locate the row before they can touch it. Because email is UNIQUE, PostgreSQL already has an index for it, so both plans show an Index Scan on that index underneath Update on users / Delete on users, costed around 0.15..8.17. That's higher than the insert, but still cheap — the same index-vs-scan tradeoff from the indexes lesson, just triggered by a write instead of a plain SELECT. With PostgreSQL's default cost constants (random_page_cost = 4), the 8.17 is mostly two page reads: one to read the matching entry in the email index, one to fetch the actual row from the table — 4 + 4, plus a sliver of cpu_tuple_cost for evaluating the condition and handing back the one row. The tiny 0.15 startup cost is just descending the index to find that first matching entry — small here because the table (and its index) only spans a page or two.

UPSERT

PostgreSQL has no dedicated UPSERT keyword. MySQL has INSERT ... ON DUPLICATE KEY UPDATE; SQL Server and Oracle have MERGE. PostgreSQL instead extends INSERT itself: ON CONFLICT lets that single statement insert a new row or update the existing one, depending on whether it already exists — atomically, with no race condition between a separate check and a follow-up write. The absence of this feature from Postgres has been a long-standing complaint from Postgres users [2] [3] [4] [5].

INSERT INTO users (name, email, gender, active)
VALUES ('Katherine Johnson', 'katherine@example.com', 'female', false)
ON CONFLICT (email)
DO UPDATE SET active = EXCLUDED.active
RETURNING *;
idnameemailgenderactive
4Katherine Johnsonkatherine@example.comfemalefalse

Katherine Johnson's id doesn't change — this updated the existing row instead of erroring on the duplicate email or inserting a second one.

Explain the explain

It's still an Insert on users plan — PostgreSQL always attempts the insert first.

Insert on users  (cost=0.00..0.01 rows=1 width=101)
  Conflict Resolution: UPDATE
  Conflict Arbiter Indexes: users_email_key
  ->  Result  (cost=0.00..0.01 rows=1 width=101)

Two lines make it an upsert: Conflict Resolution: UPDATE and Conflict Arbiter Indexes: users_email_key. The second one names the unique index PostgreSQL checks to decide whether a row already exists. If email collides, it runs the DO UPDATE SET instead of raising a duplicate-key error — no index on the conflict column, no upsert.

Deactivate every male user (bulk update)

A WHERE clause can match more than one row. UPDATE changes every match, and RETURNING hands back all of them.

UPDATE users
SET active = false
WHERE gender = 'male'
RETURNING *;
idnameemailgenderactive
3Alan Turingalan@example.commalefalse
6Donald Knuthdonald@example.commalefalse
7John McCarthyjohn@example.commalefalse
8Edsger Dijkstraedsger@example.commalefalse

Explain the explain

gender has no index, unlike email.

Update on users  (cost=0.00..17.88 rows=3 width=7)
  ->  Seq Scan on users  (cost=0.00..17.88 rows=3 width=7)
        Filter: (gender = 'male'::text)

The plan falls back to Seq Scan on users with Filter: (gender = 'male') — PostgreSQL reads every row in the table, keeps the ones that match, and updates each one it keeps. RETURNING * hands back every row it touched, not just one. On a small table this is nothing; on a large one, an index on gender would turn that seq scan into an index scan the same way it did for email above.

UPSERT vs DELETE + INSERT

Refreshing an existing row is often written as a DELETE followed by an INSERT. It reaches the same end state as the upsert above, but not the same way under the hood — deleting marks the old row dead and writes the replacement somewhere new, while ON CONFLICT updates the existing row in place. A full comparison walks through the difference in detail, including the VACUUM cost DELETE + INSERT leaves behind.

A VACUUM is a cleanup process for rows marked as deleted — either run by hand (VACUUM users;) or triggered automatically in the background by autovacuum. It scans the table for those leftover rows and marks their space reusable, so the table doesn't just keep growing with every write.

VACUUM is needed because PostgreSQL internally implements MVCC (Multi-Version Concurrency Control): it never overwrites a row version in place, because some other transaction might still need to see the version that was there before. A DELETE just marks that row's version as no longer current — the bytes stay on the disk page as a dead tuple until VACUUM reclaims the space. An UPDATE (what ON CONFLICT DO UPDATE runs) leaves a dead tuple too — MVCC doesn't make an exception for it — but when the new version fits on the same page and no indexed column changed, it qualifies as a HOT (Heap-Only Tuple) update: no other index has to be touched, and PostgreSQL can often clean up that dead space on its own without waiting for a full VACUUM pass. A DELETE followed by a separate INSERT never gets that shortcut. Learn more in the dedicated lesson about MVCC.

UPDATE (HOT)✓same page, index untouchedemail idxpage 3old row(dead)new row(live)no other index has to changeDELETE + INSERT⚠new page, index touched twiceemail idxpage 3old row(dead)page 9new row(live)removedaddedold entry dropped, new entry addedlive tupledead tuple (needs VACUUM)

Same scenario as the upsert above — deactivate Katherine Johnson — done two ways, with EXPLAIN (ANALYZE, BUFFERS) instead of a plain EXPLAIN. This actually runs the statement and reports real timing and page reads, not just an estimate.

DELETE + INSERT

UPSERT

Removing Katherine Johnson's row so it can be reinserted from scratch.

EXPLAIN (ANALYZE, BUFFERS)
DELETE FROM users
WHERE email = 'katherine@example.com';
QUERY PLAN
Delete on users (cost=0.15..8.17 rows=0 width=0) (actual time=0.325..0.336 rows=0.00 loops=1)
Buffers: shared hit=3
-> Index Scan using users_email_key on users (cost=0.15..8.17 rows=1 width=6) (actual time=0.040..0.046 rows=1.00 loops=1)
Index Cond: (email = 'katherine@example.com'::text)
Index Searches: 1
Buffers: shared hit=2
Planning:
Buffers: shared hit=49 read=1
Planning Time: 0.277 ms
Execution Time: 0.587 ms

Reinserting her as a brand new row, right after the DELETE above.

EXPLAIN (ANALYZE, BUFFERS)
INSERT INTO users (name, email, gender, active)
VALUES ('Katherine Johnson', 'katherine@example.com', 'female', false);
QUERY PLAN
Insert on users (cost=0.00..0.01 rows=0 width=0) (actual time=0.159..0.161 rows=0.00 loops=1)
Buffers: shared hit=45
-> Result (cost=0.00..0.01 rows=1 width=101) (actual time=0.030..0.031 rows=1.00 loops=1)
Buffers: shared hit=14
Planning Time: 0.029 ms
Execution Time: 0.181 ms

Same end state, one statement: update her row in place instead of deleting and reinserting it.

EXPLAIN (ANALYZE, BUFFERS)
INSERT INTO users (name, email, gender, active)
VALUES ('Katherine Johnson', 'katherine@example.com', 'female', false)
ON CONFLICT (email)
DO UPDATE SET active = EXCLUDED.active;
QUERY PLAN
Insert on users (cost=0.00..0.01 rows=0 width=0) (actual time=0.099..0.101 rows=0.00 loops=1)
Conflict Resolution: UPDATE
Conflict Arbiter Indexes: users_email_key
Tuples Inserted: 0
Conflicting Tuples: 1
Buffers: shared hit=31
-> Result (cost=0.00..0.01 rows=1 width=101) (actual time=0.030..0.032 rows=1.00 loops=1)
Buffers: shared hit=14
Planning:
Buffers: shared hit=25
Planning Time: 0.078 ms
Execution Time: 0.249 ms

Look at the Buffers line in each plan — it counts real page touches, not an estimate. DELETE + INSERT pays for two statements: a lookup through the email index to find the old row, then a whole new tuple written elsewhere in the table — with a new id, since the sequence never rewinds after a delete — and every index loses an entry and gains a new one. UPSERT pays for the same conflict-checking lookup, but updates the tuple it already found instead: same id, one statement, and (usually) no other index touched. Run this enough times and that gap compounds — here's what the article linked above measured on a 100k-row table, single-row operations:

ApproachTotal TimeBuffer HitsDisk I/ODead Tuples
DELETE + INSERT0.423 ms10HigherYes
UPSERT (ON CONFLICT)0.145 ms5LowerNo

In their benchmark, the UPSERT approach is ~2.9x faster and uses 50% fewer buffer hits than DELETE + INSERT.

Try Yourself

Loaded Database:insert-update-delete.database-init

The database for this lesson is already loaded. Write any query you want and run it directly in your browser.

Exercises

0 OF 4 DONE
1.Insert Margaret Hamilton with email margaret@nasa.gov and gender female, and return the inserted row.
2.Set Ada Lovelace to inactive and return the updated row.
3.Delete every male user and return all the deleted rows.
4.Upsert Barbara Liskov: email barbara@example.com, gender female, active false. Since that email already exists, this should update her row instead of inserting a new one. Return the affected row.

What We Learned

  • INSERTadds new rows to a table.
  • UPDATEchanges rows that match a WHERE clause — every match, not just one.
  • DELETEremoves rows that match a WHERE clause.
  • RETURNINGturns writes into visible result rows.
  • INSERT ... ON CONFLICTan upsert: insert a new row, or update the existing one if it already conflicts on a unique column.
  • MVCCPostgreSQL never overwrites a row version in place. A DELETE or UPDATE leaves the old version behind as a dead tuple instead of erasing it.
  • VACUUMreclaims the space dead tuples leave behind. A HOT update can often clean some of that up on its own; DELETE + INSERT always needs a real VACUUM pass.
  • HOT updatea Heap-Only Tuple update: when the new row version fits on the same page and no indexed column changed, PostgreSQL updates it without touching any other index.