I recently commented on Jonathan Lewis’s blog, Savepoint Funny, where I compared how PostgreSQL handles uniqueness differently: “PostgreSQL resolves uniqueness through heap tuple visibility”. This deserves a more detailed explanation.
In Oracle, unique indexes store unique entries because the B-tree key is the index key, preventing duplicates. Non-unique indexes add the ROWID to ensure that all entries are physically unique, even when indexed column values are duplicated.
In PostgreSQL, all indexes, even unique ones, created explicitly by CREATE UNIQUE INDEX or implicitly to enforce a unique constraint, behave like non-unique indexes by appending the TID (tuple ID, similar to Oracle’s ROWID) to the index key. This indicates that the index itself doesn’t guarantee physical uniqueness, allowing multiple entries to have identical logical keys but point to different heap tuples. The actual uniqueness verification occurs at the heap level, not within the index entries.
Initially, this might seem unusual—a unique index that permits duplicates. However, PostgreSQL requires this because of its MVCC system. MVCC allows duplicate entries to coexist in an index, since they can represent different versions of the same logical row. Still, PostgreSQL must guarantee that no MVCC snapshot views two rows with the same index key. Oracle doesn’t face this issue because its MVCC implementation also versions index blocks, allowing a single index version to maintain unique keys.
Let’s show that.
Page inspect
In PostgreSQL, the heap contains the table data, and index entries point to heap tuples. Visibility depends on the heap header, especially the transaction information. Index scans often visit the heap pages to check visibility, except for index-only scans, which use the heap’s visibility maps as an optimization. B-tree indexes can store entries for multiple versions of the same logical row, including versions that are no longer visible to current snapshots. To ensure uniqueness, the B-tree must check the heap for matching keys to verify whether multiple entries point to visible heap tuples in the same MVCC snapshot.
I used pageinspect to look at heap and index pages. This is not application-level SQL. This is a physical page inspection, useful for debugging and understanding internals. I used it to see all tuple versions, including those not visible to my MVCC snapshot. In some ways, you can compare it to Oracle flashback query, which shows all versions of a row.
CREATE EXTENSION IF NOT EXISTS pageinspect;
DROP TABLE IF EXISTS demo_unique_mvcc;
CREATE TABLE demo_unique_mvcc
(
id bigint generated always as identity,
email text not null,
payload text not null,
marker int not null,
CONSTRAINT demo_unique_mvcc_email_key UNIQUE (email)
) WITH ( FILLFACTOR = 10 );
CREATE INDEX demo_unique_mvcc_marker_idx
ON demo_unique_mvcc(marker);
ALTER TABLE demo_unique_mvcc SET (autovacuum_enabled = false);
Enter fullscreen mode Exit fullscreen mode
I disabled auto-vacuum to avoid garbage collection during my experimentation.
Unique index
I created a unique constraint on email:
postgres=# d demo_unique_mvcc
Table "public.demo_unique_mvcc"
Column | Type | Collation | Nullable | Default
---------+---------+-----------+----------+------------------------------
id | bigint | | not null | generated always as identity
email | text | | not null |
payload | text | | not null |
marker | integer | | not null |
Indexes:
"demo_unique_mvcc_email_key" UNIQUE CONSTRAINT, btree (email)
"demo_unique_mvcc_marker_idx" btree (marker)
postgres=#
Enter fullscreen mode Exit fullscreen mode
I also created another index on marker. This is deliberate. If I update only a non-indexed column, PostgreSQL may use HOT updates, so no new entries are needed in the indexes. I want a non-HOT update to show the general case, so I update an indexed column, marker, while keeping the same email. To prove my point, I’ve set FILLFACTOR to 10% so that HOT updates are possible.
I inserted one row:
postgres=# INSERT INTO demo_unique_mvcc(email, payload, marker)
VALUES ('[email protected]', 'first version', 1)
;
INSERT 0 1
postgres=# SELECT ctid, xmin, xmax, *
FROM demo_unique_mvcc
;
ctid | xmin | xmax | id | email | payload | marker
-------+------+------+----+---------------+---------------+--------
(0,1) | 697 | 0 | 1 | [email protected] | first version | 1
(1 row)
Enter fullscreen mode Exit fullscreen mode
The precise xmin may vary as it’s your transaction identifier. What’s crucial is the ctid. In this case, the visible row version is (0,1), which is the first tuple on the first page.
Now let’s look at the heap page:
postgres=# SELECT lp, t_xmin, t_xmax, t_ctid, t_infomask, t_infomask2
FROM heap_page_items(get_raw_page('demo_unique_mvcc', 0))
ORDER BY lp
;
lp | t_xmin | t_xmax | t_ctid | t_infomask | t_infomask2
----+--------+--------+--------+------------+-------------
1 | 697 | 0 | (0,1) | 2306 | 4
(1 row)
Enter fullscreen mode Exit fullscreen mode
This tuple was inserted by committed transaction 697, has not been deleted or updated (xmax is invalid), contains at least one variable-length column, and stores 4 attributes.
I inspect the unique index. For a B-tree index, block 0 is the metapage, so the first leaf page is usually block 1 for a tiny index:
postgres=# SELECT itemoffset, ctid, htid, dead,
data, encode(decode(replace(substr(data,4),' ',''), 'hex'),'escape')
FROM bt_page_items('demo_unique_mvcc_email_key', 1)
ORDER BY itemoffset
;
itemoffset | ctid | htid | dead | data | encode
------------+-------+-------+------+-------------------------------------------------+-----------------------
1 | (0,1) | (0,1) | f | 1d 61 40 65 78 61 6d 70 6c 65 2e 63 6f 6d 00 00 | [email protected]