Fixing PostgreSQL Upsert That Overwrites Unchanged Rows and Breaks updated_at
You bulk-load records with INSERT ... ON CONFLICT DO UPDATE, check your table afterward, and every single row has a fresh updated_at timestamp β even the ones where nothing actually changed. Your audit trail is now meaningless, and any downstream process that polls for recent changes is going to reprocess the entire dataset.
This is one of the most common PostgreSQL gotchas, and the fix is simpler than most people expect once you understand what the engine is actually doing under the hood.
What you'll learn
- Why
ON CONFLICT DO UPDATEfires even when the incoming row is identical to the stored one - How to add a
WHEREclause to theDO UPDATEso only genuinely changed rows are touched - How to scale that guard to tables with many columns without writing a wall of SQL
- How to back the guard with a trigger so the rule is enforced at the database level
- The subtle pitfalls that can still bite you after you think you've fixed it
Prerequisites
- PostgreSQL 9.5 or later (that's when
ON CONFLICTwas introduced) - Basic familiarity with
INSERT,UPDATE, and table constraints - A table that has an
updated_at TIMESTAMPTZcolumn you want to preserve correctly
The Problem in Plain Terms
Let's anchor this with a concrete table. Suppose you have a products table that a nightly ETL job refreshes from an upstream source:
CREATE TABLE products (
id BIGINT PRIMARY KEY,
sku TEXT NOT NULL,
price NUMERIC(10, 2) NOT NULL,
stock INT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Your ETL issues an upsert that looks roughly like this:
INSERT INTO products (id, sku, price, stock, updated_at)
VALUES
(1, 'WIDGET-A', 9.99, 100, now()),
(2, 'WIDGET-B', 14.99, 50, now())
ON CONFLICT (id) DO UPDATE
SET sku = EXCLUDED.sku,
price = EXCLUDED.price,
stock = EXCLUDED.stock,
updated_at = EXCLUDED.updated_at;
Even if WIDGET-A still costs $9.99 and still has 100 units in stock, it gets a new updated_at. PostgreSQL doesn't compare old and new values for you β it just executes the SET clause unconditionally.
How INSERT ... ON CONFLICT DO UPDATE Actually Works
PostgreSQL evaluates the conflict target (here, the id primary key) and, on a collision, switches from an insert to an update. The EXCLUDED pseudo-table holds the row that would have been inserted. PostgreSQL then runs the SET clause as a normal UPDATE against the existing row.
Crucially, PostgreSQL does not check whether the new values differ from the old ones before running that update. It just runs it. This means the row gets a new MVCC version written to disk, any triggers fire, and anything watching updated_at sees the change β even when the payload was identical.
This behavior is by design and is documented. The engine optimizes for predictability over implicit diffing. The responsibility for filtering no-op updates falls on you.
Why Every Row Gets a New updated_at
There are two distinct ways this breaks updated_at:
- You set it explicitly in the
SETclause tonow()orEXCLUDED.updated_at, so it always gets the current time regardless of whether any other column changed. - You have a trigger that sets
updated_at = now()on everyUPDATE, and the trigger fires because the upsert runs the update path unconditionally.
Both require a fix, but in different places. We'll cover the explicit SET case first, then revisit the trigger case.
The Fix: A WHERE Clause on the DO UPDATE
PostgreSQL lets you attach a WHERE clause to ON CONFLICT DO UPDATE. If the condition evaluates to false, the update is skipped entirely β the row is left exactly as it is. This is the standard approach for preventing no-op writes.
INSERT INTO products (id, sku, price, stock, updated_at)
VALUES
(1, 'WIDGET-A', 9.99, 100, now()),
(2, 'WIDGET-B', 14.99, 50, now())
ON CONFLICT (id) DO UPDATE
SET sku = EXCLUDED.sku,
price = EXCLUDED.price,
stock = EXCLUDED.stock,
updated_at = now()
WHERE
products.sku IS DISTINCT FROM EXCLUDED.sku
OR products.price IS DISTINCT FROM EXCLUDED.price
OR products.stock IS DISTINCT FROM EXCLUDED.stock;
Notice two things. First, updated_at is no longer set from EXCLUDED.updated_at β you set it to now() only when the WHERE condition is true, meaning a real change occurred. Second, the comparisons use IS DISTINCT FROM instead of <>. This matters because NULL <> NULL evaluates to NULL (not TRUE), so nullable columns would silently skip the comparison. IS DISTINCT FROM treats NULL as a comparable value.
When WIDGET-A arrives with unchanged data, the WHERE condition is false, the update is skipped, and updated_at stays at whatever it was last set to. Only rows with actual changes get a new timestamp.
Handling Multiple Columns Without Going Insane
The column-by-column comparison gets verbose fast on wide tables. A cleaner approach is to cast both the incoming and stored rows to a composite type and compare them in one shot using ROW():
INSERT INTO products (id, sku, price, stock, updated_at)
VALUES
(1, 'WIDGET-A', 9.99, 100, now())
ON CONFLICT (id) DO UPDATE
SET sku = EXCLUDED.sku,
price = EXCLUDED.price,
stock = EXCLUDED.stock,
updated_at = now()
WHERE
ROW(products.sku, products.price, products.stock)
IS DISTINCT FROM
ROW(EXCLUDED.sku, EXCLUDED.price, EXCLUDED.stock);
The ROW() constructor compares element by element and respects NULL semantics correctly. Deliberately exclude updated_at itself from both sides of the comparison β you don't want the timestamp to affect whether the timestamp gets updated, which would be circular.
This approach scales well to 20-column tables without turning into a maintenance nightmare. If you add a new business-data column, add it to both the SET clause and both sides of the ROW() comparison. Keep updated_at, created_at, and any surrogate keys out of that comparison.
If you work with complex queries like this alongside other tricky SQL patterns, the article on fixing PostgreSQL window functions that return the wrong rank on tied partitions covers a similar mindset of understanding what the engine does versus what you intend.
Using a Trigger Instead of an Application-Level Guard
If multiple services write to this table, or if you can't guarantee every upsert query includes the WHERE guard, push the logic into the database with a trigger.
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
-- Only update the timestamp if a non-metadata column actually changed
IF ROW(NEW.sku, NEW.price, NEW.stock)
IS DISTINCT FROM
ROW(OLD.sku, OLD.price, OLD.stock)
THEN
NEW.updated_at := now();
ELSE
NEW.updated_at := OLD.updated_at;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_products_updated_at
BEFORE UPDATE ON products
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
With this trigger, even a naive upsert that sets updated_at = now() unconditionally will be corrected: the trigger runs before the row is written and resets updated_at to OLD.updated_at if nothing meaningful changed.
The tradeoff is that the update still writes a new row version to disk β it just has the same updated_at. If your primary concern is write amplification and table bloat, the WHERE clause approach on the upsert statement is better because it prevents the write entirely. If your primary concern is timestamp accuracy across many writers, the trigger is more robust.
Combining Both Approaches
You can use both: a WHERE clause on the upsert to skip disk writes for unchanged rows, plus a trigger as a safety net for any application path that bypasses the guard. The trigger only fires when an update actually reaches the row, so there's no double-penalty.
Common Pitfalls
Forgetting IS DISTINCT FROM on Nullable Columns
If any column in your comparison is nullable and you use = or <>, a NULL value will silently fail the comparison. You'll either always update or never update those rows correctly. Always use IS DISTINCT FROM for any column that could be NULL. It costs nothing extra in practice and is always safe to use even on NOT NULL columns.
Including updated_at in the Comparison
If you accidentally include updated_at in the ROW() comparison, the condition will almost always be true (because EXCLUDED.updated_at is now() and products.updated_at is some past time), defeating the entire guard. Keep metadata columns out of the business-data comparison.
ON CONFLICT DO NOTHING Is Not the Answer
A common instinct when you want to avoid touching unchanged rows is to switch to ON CONFLICT DO NOTHING. That skips the update entirely β which means legitimately changed rows also won't be updated. It's correct only for pure deduplication, not for keeping data current.
The Upsert WHERE Clause Does Not Filter Which Rows Conflict
The WHERE clause on DO UPDATE is not a filter on which incoming rows attempt the upsert. It's a condition on whether to execute the update after a conflict is detected. The conflict detection itself (the ON CONFLICT (id) part) always runs. If the WHERE evaluates to false, PostgreSQL effectively treats the statement as DO NOTHING for that row.
Partial Indexes as Conflict Targets
If your conflict target is a partial index rather than a primary key or unique constraint, you must reference the index's predicate in the ON CONFLICT clause. PostgreSQL won't infer it. Forgetting this causes the statement to fail or fall back to an unintended constraint, which can produce phantom conflicts or missed upserts.
If this situation comes up and you're also debugging row-level anomalies in Python pipelines, the article on fixing Pandas merge producing duplicate rows on non-unique keys shows a parallel pattern of data-identity problems that arise from unexpected key collisions.
Transaction Isolation and Race Conditions
Under high-concurrency write loads, two sessions can both read the same row, both decide it needs updating, and both proceed to write. This is a standard upsert race condition, not specific to the WHERE guard. If you need strict idempotency guarantees in concurrent workloads, consider advisory locks or a serializable isolation level for the upsert transaction.
Wrapping Up
PostgreSQL's ON CONFLICT DO UPDATE is intentionally dumb about diffing old and new values β that's your job. The core fix is a three-line addition to your upsert:
- Add a
WHEREclause toDO UPDATEthat compares the business-data columns usingIS DISTINCT FROM, or aROW()composite comparison for wide tables. - Set
updated_at = now()inside theSETclause, not fromEXCLUDED.updated_at, so it only gets stamped when theWHEREguard passes. - Add a
BEFORE UPDATEtrigger if multiple application paths write to the table and you can't guarantee every writer includes the guard. - Test with a second identical upsert right after the first:
updated_atshould not change on the second run if no data changed. - Exclude metadata columns (
updated_at,created_at, internal IDs) from theROW()comparison β comparing them causes circular or always-true conditions.
Frequently Asked Questions
How do I stop PostgreSQL upsert from updating rows when nothing has changed?
Add a WHERE clause to your ON CONFLICT DO UPDATE block that compares the incoming values (via EXCLUDED) to the stored values using IS DISTINCT FROM. If no business-data column has changed, the condition evaluates to false and the update is skipped entirely.
Why does updated_at change even when my upsert data is identical to what's already in the table?
PostgreSQL's ON CONFLICT DO UPDATE executes the SET clause unconditionally whenever a conflict is detected β it does not automatically compare old and new values. Without a WHERE guard, every conflicting row gets updated and your timestamp refreshes regardless of whether the payload actually changed.
Should I use ON CONFLICT DO NOTHING to avoid touching unchanged rows?
Only if you never want to update existing rows at all. DO NOTHING skips the update for every conflicting row, including ones with genuinely new data. For a real upsert where you want updates only when data changes, use DO UPDATE with a conditional WHERE clause instead.
What is the difference between using IS DISTINCT FROM and <> when comparing columns in a PostgreSQL upsert WHERE clause?
The <> operator returns NULL when either side is NULL, which means comparisons involving nullable columns silently fail to detect differences. IS DISTINCT FROM treats NULL as a concrete value, so NULL IS DISTINCT FROM NULL is false (they are equal) and NULL IS DISTINCT FROM 'some value' is true (they differ). Always prefer IS DISTINCT FROM for nullable columns in upsert guards.
Can a PostgreSQL trigger fix the updated_at problem instead of changing the upsert query?
Yes. A BEFORE UPDATE trigger can compare OLD and NEW row values and reset NEW.updated_at to OLD.updated_at when no meaningful column changed. This is a good safety net when multiple application paths write to the same table, but the upsert still writes a new row version to disk, so it does not reduce write amplification the way a WHERE clause guard does.
π€ Share this article
Sign in to saveRelated Articles
How-To Guides
Fixing Excel COUNTIFS That Returns Wrong Count When Criteria Use OR Logic
1m read
How-To Guides
Fixing Excel XLOOKUP That Returns Wrong Value When Search Array Has Merged Cells
9m read
How-To Guides
Fixing Excel INDEX MATCH #VALUE! Error When Array Formula Spans Multiple Columns
9m read
Comments (0)
No comments yet. Be the first!