Mont de Marsan, le 6 février 2026

Le type de donnée Universally unique identifier ou UUID est très apprécié dans les architectures distribuées. En effet, une séquence classique (SERIAL, IDENTITY) ne garantit l’unicité qu’au sein d’une seule base de données, là où un UUID est voulu unique au monde par construction, tout en étant généré localement, sans coordination centrale.

Cependant leur nature aléatoire pose un problème de performance mal connu (sauf des DBA PostgreSQL…): la fragmentation des index. Voyons comment PostgreSQL 18 qui implémente les UUID v7 résout ce problème.

Alain Lesage

Le problème des UUID v4

La fonction gen_random_uuid(), apparue avec PostgreSQL 13, génère des UUID version 4, c’est-à-dire des identifiants de 128 bits complètement aléatoires. Cette propriété est idéale pour éviter les collisions dans les systèmes distribués, mais elle a un effet de bord délétère sur les performances.

Lorsqu’on insère des lignes avec un UUID v4 comme clé primaire, chaque nouvel UUID atterrit à un endroit aléatoire dans l’index B-tree associé à cette clé. Ceci a pour conséquence que les insertions sont dispersées dans tout l’index, rendant le cache peu efficace : chaque page lue n’est utilisée qu’une fois avant d’être évincée.

La solution : UUID version 7

Les UUID v7, définis par la RFC 9562, résolvent ce problème en intégrant un timestamp dans les premiers bits de l’identifiant. Les UUID générés sont donc naturellement triés par ordre chronologique.

PostgreSQL 18 ajoute de nouvelles fonctions :

uuidv4()                      -- Alias de gen_random_uuid()
uuidv7()                      -- Génère un UUID v7
uuid_extract_version(uuid)    -- Retourne la version (1 à 7)
uuid_extract_timestamp(uuid)  -- Extrait la date de création (v1, v7)

Comparaison

Générons quelques UUID des deux versions pour comprendre les différences :

SELECT uuidv4() AS uuid_v4, uuidv7() AS uuid_v7
FROM generate_series(1, 5);
               uuid_v4                |               uuid_v7
--------------------------------------+--------------------------------------
 f8856df2-1638-4478-81ad-0b1be6ab94b7 | 019c2ada-69d6-794a-bf1c-207ea936bb8c
 6ebf42db-6fa7-4ee5-9427-c010fc298630 | 019c2ada-69d6-7967-abd3-ace26cbbe5d1
 9af32354-efd4-4e85-9c5b-12422cfb8420 | 019c2ada-69d6-796d-95d4-7e1c221fc193
 654fa0c3-7398-4a1a-819d-57d003d8afd6 | 019c2ada-69d6-7972-8f04-314ff096437d
 41bf3daa-76ac-46d1-9174-72e6f9dda78c | 019c2ada-69d6-7976-b785-8b92ae4329c2

Les UUID v7 partagent un préfixe commun (le timestamp) tandis que les UUID v4 sont complètement aléatoires. Ce préfixe commun est la clé de leur efficacité dans les index B-tree.

Impact sur la taille des index

Créons deux tables avec 500 000 lignes chacune :

-- Table avec UUID v4
CREATE TABLE t_uuidv4 (id uuid PRIMARY KEY, data text);
INSERT INTO t_uuidv4
SELECT uuidv4(), 'ligne ' || x
FROM generate_series(1, 500000) AS F(x);

-- Table avec UUID v7
CREATE TABLE t_uuidv7 (id uuid PRIMARY KEY, data text);
INSERT INTO t_uuidv7
SELECT uuidv7(), 'ligne ' || x
FROM generate_series(1, 500000) AS F(x);

VACUUM ANALYZE t_uuidv4, t_uuidv7;

Comparons la taille des index :

SELECT relname AS index, relpages AS pages
FROM pg_class
WHERE relname LIKE 't_uuid%pkey'
ORDER BY relname;
     index     | pages
---------------+-------
 t_uuidv4_pkey |  2434
 t_uuidv7_pkey |  1927

L’index UUID v7 est 21 % plus petit ! Les insertions séquentielles permettent un meilleur remplissage des pages de l’index.

Colocalité des données

Au-delà de la taille, c’est la localité des données qui fait la différence. Regardons où se trouvent physiquement les 10 plus petits UUID de chaque table :

-- UUID v4 : accès par index (10 plus petits UUID)
SELECT id, ctid, (ctid::text::point)[0]::int AS page
FROM t_uuidv4
ORDER BY id
LIMIT 5;
                  id                  |    ctid    | page
--------------------------------------+------------+------
 00001a5f-3538-4f1b-a5c5-0a1a3703a15d | (210,8)    |  210
 000025eb-4928-418f-b418-a5b4693d0f02 | (67,62)    |   67
 00002f6c-ae0b-4eaf-856c-26cb41de7ea0 | (252,97)   |  252
 0000316a-83fb-4cb8-bdee-9f867d20d83a | (3297,33)  | 3297
 0000335b-6c7a-4678-a989-ba5603b69317 | (806,37)   |  806
 ...

Ayant été générés à des moments complètement différents, les 10 plus petits UUID v4, bien que contigus dans l’index, pointent vers des pages dispersées aléatoirement dans la table. Et inversement, les UUID d’une même page de la table sont dispersés dans tout l’index.

-- UUID v7 : accès par index (10 plus petits UUID)
SELECT id, ctid, (ctid::text::point)[0]::int AS page
FROM t_uuidv7
ORDER BY id
LIMIT 5;
                  id                  |  ctid  | page
--------------------------------------+--------+------
 019c2adc-fa3d-7711-ae51-43d144c4a169 | (0,1)  |    0
 019c2adc-fa3e-76e4-9094-94cb6398b0a9 | (0,2)  |    0
 019c2adc-fa3e-770d-baa4-149bd7d53633 | (0,3)  |    0
 019c2adc-fa3e-7716-a416-77105da270a2 | (0,4)  |    0
 019c2adc-fa3e-771d-9679-be4d91be9c8c | (0,5)  |    0
 ...

Avec UUID v7, les plus petits UUID (les plus anciens) sont dans les premières pages de la table. La sélection d’une plage d’id consécutifs sur une table UUID v7 lira des pages contiguës, tandis que sur UUID v4, elle fera des accès aléatoires à travers toute la table. Vous n’avez peut-être pas de requête métier qui font ce genre de sélection d’id, mais nous verrons plus loin qu’une pénalité demeure sur l’efficacité du processus autovacuum.

Tri chronologique natif

Puisque les UUID v7 contiennent un timestamp, ils sont naturellement triables par date de création :

SELECT
    id,
    uuid_extract_timestamp(id) AS created_at
FROM t_uuidv7
ORDER BY id DESC
LIMIT 3;
                  id                  |         created_at
--------------------------------------+----------------------------
 019c2add-08da-77d8-8834-7a34d2075185 | 2026-02-04 22:54:14.746+00
 019c2add-08da-77d2-9ee7-e296fd1bc85a | 2026-02-04 22:54:14.746+00
 019c2add-08da-77cc-837f-e3399d65ffb7 | 2026-02-04 22:54:14.746+00

ORDER BY id équivaut à ORDER BY created_at ! Cette propriété peut vous permettre d’économiser une colonne created_at et son index associé.

Impact sur VACUUM

Un effet de la fragmentation dans les index concerne VACUUM. Lors d’une purge de données anciennes (typiquement DELETE FROM sessions WHERE created_at < now() - interval '30 days'), les lignes supprimées sont :

  • Dispersées dans toutes les pages avec UUID v4 (insertions aléatoires)
  • Groupées dans les premières pages avec UUID v7 (insertions chronologiques)

VACUUM peut même bypasser le scan d’index si moins de 2% des pages de la table contiennent des tuples morts. Démonstration :

-- Deux tables de sessions avec 10 000 lignes sur 100 jours
CREATE TABLE sessions_v4 (
    id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
    created_at TIMESTAMP NOT NULL,
    data TEXT
);

CREATE TABLE sessions_v7 (
    id UUID DEFAULT uuidv7() PRIMARY KEY,
    created_at TIMESTAMP NOT NULL,
    data TEXT
);

-- Insertion de 10 000 lignes réparties sur 100 jours
INSERT INTO sessions_v4 (created_at, data)
SELECT '2026-01-01'::timestamp + ((i-1) / 100) * interval '1 day',
       repeat('x', 100)
FROM generate_series(1, 10000) i;

INSERT INTO sessions_v7 (created_at, data)
SELECT '2026-01-01'::timestamp + ((i-1) / 100) * interval '1 day',
       repeat('x', 100)
FROM generate_series(1, 10000) i;

-- Réorganisation physique selon l'index UUID (maintenance courante)
CLUSTER sessions_v4 USING sessions_v4_pkey;
CLUSTER sessions_v7 USING sessions_v7_pkey;
VACUUM ANALYZE sessions_v4, sessions_v7;

-- Suppression d'un jour de données (1% des lignes)
DELETE FROM sessions_v4 WHERE created_at < '2026-01-02';
DELETE FROM sessions_v7 WHERE created_at < '2026-01-02';

Exécutons VACUUM (VERBOSE) sur les deux :

-- sessions_v4
INFO:  vacuuming "postgres.public.sessions_v4"
INFO:  finished vacuuming "postgres.public.sessions_v4": index scans: 1
pages: 0 removed, 193 remain, 193 scanned (100.00% of total), 0 eagerly scanned
[…]
index "sessions_v4_pkey": pages: 41 in total, 0 newly deleted, 0 currently deleted, 0 reusable

-- sessions_v7
INFO:  vacuuming "postgres.public.sessions_v7"
INFO:  finished vacuuming "postgres.public.sessions_v7": index scans: 0
pages: 0 removed, 193 remain, 3 scanned (1.55% of total), 0 eagerly scanned
[…]
index scan bypassed: 2 pages from table (1.04% of total) have 100 dead item identifiers

Avec UUID v4, les 100 lignes supprimées (1% des données), VACUUM doit scanner 100 % (!) des pages de la table et effectuer un scan complet de l’index (index scans: 1).

Avec UUID v7, les mêmes 100 lignes sont groupées, VACUUM peut bypasser le scan d’index (index scans: 0) et ne parcourt que 1.55% des pages de la table, l’autovacuum sera d’autant plus efficace, n’ayant que 3 pages à parcourir au lieu de 234 (table + index), soit 78 fois moins !!

Notre exemple, volontairement simplifié, permet d’observer deux phénomènes distincts :

  • le bypass du scan d’index, optimisation de VACUUM ;
  • moins de pages à parcourir sur la table.

En production, ces deux effets ne sont pas toujours liés, l’avantage de UUID v7 sera donc généralement moins spectaculaire que dans cette démonstration, mais néanmoins bien réel.

À retenir

Les UUID v7 offrent des index plus compacts, une excellente colocalité des données, un tri chronologique natif et un VACUUM nettement plus efficace. Ils sont particulièrement adaptés aux tables à fort volume d’insertions, aux tables partitionnées par date, et aux systèmes distribués. Les UUID v4 restent pertinents si l’imprévisibilité de l’identifiant est requise pour des raisons de sécurité, mais leur utilisation a des conséquences, que vous pourrez désormais anticiper.

Références


DALIBO

DALIBO est le spécialiste français de PostgreSQL®. Nous proposons du support, de la formation et du conseil depuis 2005.