Postgres 15
... adds the clause NULLS NOT DISTINCT. Your case works out of the box now:
ALTER TABLE my_table
DROP CONSTRAINT IF EXISTS ux_my_table_unique
, ADD CONSTRAINT ux_my_table_unique UNIQUE NULLS NOT DISTINCT (col2, col3);
INSERT INTO my_table (col2, col3, col4)
VALUES (p_col2, p_col3, p_col4)
ON CONFLICT (col2, col3) DO UPDATE
SET col4 = EXCLUDED.col4;
See:
Postgres 14 or older
NULL values are not considered equal to each other and thus never trigger a UNIQUE violation. That means, your current table definition does not do what you say it should do. There can already be multiple rows with (col2, col3) = (1, NULL). ON CONFLICT never fires for col3 IS NULL in your current setup.
You can enforce your UNIQUE constraint with two partial UNIQUE indexes as also outlined here:
Applied to your case:
CREATE UNIQUE INDEX my_table_col2_uni_idx ON my_table (col2)
WHERE col3 IS NULL;
CREATE UNIQUE INDEX my_table_col2_col3_uni_idx ON my_table (col2, col3)
WHERE col3 IS NOT NULL;
But ON CONFLICT ... DO UPDATE can only be based on a single UNIQUE index or constraint. Only the ON CONFLICT DO NOTHING variant works as "catch-all". See:
It would seem like what you want is currently impossible, but there is a ...
Perfect solution
With the two partial UNIQUE indexes in place, you can use the right statement based on the input value of col3:
WITH input(col2, col3, col4) AS (
VALUES
(3, NULL::real, 5) -- ①
, (3, 4, 5)
)
, upsert1 AS (
INSERT INTO my_table AS t(col2, col3, col4)
SELECT * FROM input WHERE col3 IS NOT NULL
ON CONFLICT (col2, col3) WHERE col3 IS NOT NULL -- matching index_predicate!
DO UPDATE
SET col4 = EXCLUDED.col4
WHERE t.col4 IS DISTINCT FROM EXCLUDED.col4 -- ②
)
INSERT INTO my_table AS t(col2, col3, col4)
SELECT * FROM input WHERE col3 IS NULL
ON CONFLICT (col2) WHERE col3 IS NULL -- matching index_predicate!
DO UPDATE SET col4 = EXCLUDED.col4
WHERE t.col4 IS DISTINCT FROM EXCLUDED.col4; -- ②
db<>fiddle here
Works in every case.
Even works for multiple input rows with an arbitrary mix of NULL and NOT NULL values for col3.
And doesn't even cost much more than the plain statement because each row only enters into one of the two UPSERTs.
This is one of those "Eurika!" queries where everything just clicks, against all odds. :)
① Note the explicit cast to ::real in the CTE input. This related answer explains why:
② The final WHERE clause is optional, but highly recommended. It would be a waste to go through with the UPDATE if it doesn't actually change anything. See: