Le choix de la clé primaire est la décision la plus banale d’une modélisation, et dont on hérite pendant des années.

Une colonne status mal nommée se renomme un dimanche soir. Une clé primaire, elle, se propage dans toutes les clés étrangères, tous les index, tous les caches, potentiellement toutes les URLs publiques, les exports, et dans le code de tous les services qui parlent à cette base.
Elle se change rarement sans une opération de migration à part entière.

Dans les années 80, 90 et jusqu’au début des années 2000, le débat n’existait pas 👉 on utilisait des identifiants séquentiels, point.
Tout a changé à la fin des années 2000. Le web at scale, les volumes de données qui vont avec et le big data ont commencé à remettre en cause ces identifiants, au profit de formes qu’on peut générer sans passer par un organe central (la séquence) côté client ou sur N serveurs à la fois.

Les premiers que j’ai vus publier sur le sujet sont les équipes de Twitter, avec leur Snowflake ID et l’implémentation open source qui allait avec, aujourd’hui archivée.

Depuis, l’UUID est entré dans les mœurs, au point d’être devenu le choix par défaut, souvent sans qu’on prenne le recul de vérifier si c’est vraiment le bon.

Deux camps se sont formés 👇

  • D’un côté, ceux qui y voyaient la condition d’applications capables de scaler : l’identifiant se génère côté client, en quantité illimitée, sans jamais rien demander à la base.
  • De l’autre, les plus réservés, qui rappelaient que l’UUIDv4, largement majoritaire, évite certes les collisions, mais que son caractère purement aléatoire disperse les insertions sur tout l’index au lieu de les concentrer tout en multipliant les découpages de pages et alourdissant un index déjà deux fois plus gros qu’avec un BIGINT.

Et l’argument qui tranchait le débat vient de tomber : depuis la version 18, sortie en septembre 2025, PostgreSQL génère nativement des UUID ordonnés dans le temps (uuidv7()) 👉 « Les UUID détruisent vos index » n’est plus une réponse suffisante.

Cet article compare BIGINT séquentiel, UUIDv4, UUIDv7, ULID et TSID sur des mesures réelles plutôt que sur des impressions, et montre surtout dans quelles conditions chacun est le bon choix, car aucun ne l’est partout.

bigint uuid v4 uuid v7 / ulid tsid

1. Ce qu’une clé primaire coûte vraiment

Avant de comparer, il faut savoir se paie la facture. Une clé primaire dans PostgreSQL, c’est trois choses :

  • Une colonne dans la table (le heap). 8 octets pour un bigint, 16 pour un uuid, 37 pour un UUID stocké bêtement en text.
  • Un index B-tree unique, créé automatiquement. C’est lui qui souffre le plus du choix.
  • Une copie de la valeur dans chaque clé étrangère qui pointe vers la table, plus l’index sur cette clé étrangère. C’est le multiplicateur : une table référencée par cinq autres paie six fois la différence de taille.

Spécificité PostgreSQL

Beaucoup d'articles sur le sujet (souvent écrits pour MySQL/InnoDB) expliquent qu'une grosse clé primaire gonfle tous les index secondaires, parce que la table est un index clusterisé et que chaque index secondaire embarque la clé primaire dans ses feuilles.
Ce n'est pas le cas de PostgreSQL : les tables sont des heaps, et un index secondaire référence un ctid (6 octets) et non la clé primaire. Une clé primaire large est donc moins pénalisante sur PostgreSQL qu'ailleurs bien que la facture des clés étrangères, elle, reste entière.

Le deuxième coût n’apparaît sur aucun schéma : c’est celui de l’ordre d’insertion.

Un index B-tree range ses valeurs triées, réparties sur des pages de 8 Ko.
Insérer une ligne, c’est donc écrire dans la page qui correspond à sa clé. Et là, tout dépend de la clé.

Une clé croissante, comme une séquence ou un timestamp, vise toujours la même page : la dernière de l’index.
Elle reste en cache en permanence, on la remplit jusqu’au bout, puis on en ouvre une nouvelle. PostgreSQL reconnaît d’ailleurs ce cas : quand une page se remplit à l’extrémité droite de l’arbre, il ne la coupe pas en deux moitiés égales, il la laisse pleine à 90 % (valeur par défaut du fillfactor) et démarre la suivante à vide. Le résultat est un index compact, dont on ne touche jamais que la queue.

Une clé aléatoire, elle, vise une page différente à chaque insertion, tirée au hasard parmi toutes celles de l’index.

Clé croissante bigint identity · uuidv7 · ulid Clé aléatoire uuidv4 insertions insertions 90 % 90 % 90 % 10 % 72 % 50 % 50 % 70 % split 90/10 split 50/50 Pages pleines, peu de splits, pages chaudes Une page arbitraire à lire, modifier, journaliser

Trois factures s’additionnent :

  • La lecture.
    Il faut aller chercher cette page. Tant que l’index tient en RAM, ça passe. Dès qu’il la dépasse, c’est un accès disque par insertion.
  • Le WAL.
    Après chaque checkpoint, la première écriture sur une page la fait journaliser en entier : 8 Ko de WAL pour insérer 20 octets. En séquentiel, on paie ce prix une fois pour mille lignes ; en aléatoire, à peu près à chaque ligne.
  • L’espace.
    Quand une page pleine doit être coupée, PostgreSQL choisit où couper. À l’extrémité droite de l’arbre, il sait que la suite arrivera à droite : il laisse la page de gauche pleine. Ailleurs, il ne sait rien de la suite et coupe au milieu. Chaque page oscille alors entre 50 % après une coupure et 100 % avant la suivante, ce qui donne un index rempli à ~70 % en moyenne, contre ~90 % en séquentiel. Soit 30 % de pages en plus pour les mêmes données et un index qui franchit d’autant plus tôt le seuil de la RAM, ce qui ramène au premier point.
Plus de détails

Pourquoi 50/50 quand c'est aléatoire.
Quand une page est pleine et qu'il faut y insérer, PostgreSQL la coupe en deux et répartit son contenu. Reste à choisir où couper. Sur la page la plus à droite, il sait où tomberont les prochaines insertions : toutes à droite. Il a donc intérêt à laisser la page de gauche pleine car elle ne recevra plus rien, autant ne pas y gaspiller d'espace. Ailleurs dans l'arbre, il n'a aucune idée d'où tombera la suite. Couper au milieu est le choix qui maximise la place disponible des deux côtés. C'est un pari sur l'ignorance, pas un défaut.

Pourquoi ~70 % à l'équilibre.
Une page qui vient d'être coupée est à 50 %. Elle se remplit ensuite jusqu'à 100 %, puis se recoupe et retombe à 50 %. Chaque page oscille donc en permanence entre ces deux bornes. À un instant donné, l'index contient un mélange de pages à tous les stades de ce cycle. La moyenne n'est pas 75 %, mais plutôt ln(2), soit 69,3 %, un résultat classique sur les B-trees où les pages passent plus de temps dans le bas du cycle que dans le haut, parce qu'une page presque vide reçoit statistiquement moins d'insertions qu'une page presque pleine. D'où le « ~70 % », qui n'est ni un réglage ni un plafond, mais un point d'équilibre.

30% ❓
Pour stocker la même quantité de données, il faut 0,90 / 0,70 ≈ 1,29 fois plus de pages. Donc 30% de plus arrondit vers le haut.

La boucle est bouclée.
Le premier point indique : si l'index dépasse la RAM, chaque insertion coûte un accès disque.
Le troisième dit : l'aléatoire fait grossir l'index de 30 %.
👉 Les deux se relient, car un index plus gros franchit plus tôt le seuil de la RAM, et une fois ce seuil franchi, le coût par insertion change de nature. La latence disque, jusque-là absente du chemin d'insertion, s'y invite à chaque ligne.


2. La mesure

Plutôt que de répéter les intuitions du web, voici un test reproductible :

quatre tables identiques, deux millions de lignes chacune, sur PostgreSQL 18.4 (conteneur Docker, paramètres par défaut).

On démarre un container:

docker container run --rm -it --name pg18 -e POSTGRES_PASSWORD=postgres postgres:18.4

Dans un second shell :

docker container exec -it pg18 psql -h localhost -U postgres
CREATE TABLE t_bigint  (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,  label TEXT NOT NULL);
CREATE TABLE t_uuidv4  (id UUID PRIMARY KEY DEFAULT gen_random_uuid(),       label TEXT NOT NULL);
CREATE TABLE t_uuidv7  (id UUID PRIMARY KEY DEFAULT uuidv7(),                label TEXT NOT NULL);
CREATE TABLE t_uuidtxt (id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, label TEXT NOT NULL);

INSERT INTO t_bigint (label) SELECT 'row-' || g FROM generate_series(1, 2000000) g;
INSERT INTO t_uuidv4  (label) SELECT 'row-' || g FROM generate_series(1, 2000000) g;
INSERT INTO t_uuidv7  (label) SELECT 'row-' || g FROM generate_series(1, 2000000) g;
INSERT INTO t_uuidtxt (label) SELECT 'row-' || g FROM generate_series(1, 2000000) g;

Les tailles se lisent avec pg_relation_size, le WAL produit avec un pg_current_wal_lsn() avant/après, et la densité des feuilles d’index avec l’extension pgstattuple :

CREATE EXTENSION IF NOT EXISTS pgstattuple;

SELECT idx.relname, avg_leaf_density, leaf_fragmentation
FROM pg_class idx
JOIN LATERAL pgstatindex(idx.oid::regclass) ON TRUE
WHERE idx.relname LIKE 't\_%\_pkey';

Résultats :

Clé primaire Insert 2M Table Index PK WAL généré Densité feuilles Fragmentation
BIGINT identity 1,19 s 100 Mo 43 Mo 285 Mo 90,0 % 0 %
UUID v7 1,73 s 115 Mo 60 Mo 312 Mo 90,0 % 0 %
UUID v4 2,97 s 115 Mo 76 Mo 377 Mo 71,7 % 50,1 %
TEXT (UUID en chaîne) 3,99 s 154 Mo 146 Mo 470 Mo 69,5 % 49,9 %

Trois enseignements :

  1. L’entier séquentiel gagne partout, mais l’écart avec UUIDv7 est modeste : +40 % sur l’index, +10 % sur le WAL. C’est le prix de 8 octets supplémentaires par ligne, pas une catastrophe.
  2. L’aléatoire coûte cher, et il coûte deux fois. UUIDv4 paie les 16 octets et la fragmentation : son index est 27 % plus gros que celui d’UUIDv7 pour exactement les mêmes données, avec la moitié des feuilles fragmentées. La colonne « densité » explique le reste du tableau à elle seule.
  3. Stocker un UUID en text ou varchar(36) est l’erreur la plus coûteuse du lot : index 2,4× plus gros qu’en uuid, insertions 2,3× plus lentes. Le type uuid de PostgreSQL est un binaire de 16 octets, utilisez-le.

Honnêteté sur le protocole

Ces chiffres sous-estiment le problème de l'aléatoire. Ici l'index tient entièrement en mémoire et l'insertion est un INSERT ... SELECT en masse, dans une transaction unique. En production, avec un index de plusieurs gigaoctets et des insertions unitaires concurrentes, chaque insertion aléatoire devient une lecture disque : l'écart entre v4 et v7 se creuse nettement, tandis que l'écart entre bigint et UUIDv7 reste, lui, à peu près constant.

3. L’entier séquentiel

C’est le défaut historique, et il reste excellent.

CREATE TABLE invoices (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    ...
);
GENERATED ALWAYS AS IDENTITY vs BIGSERIAL

GENERATED ALWAYS AS IDENTITY est la forme standard SQL depuis PostgreSQL 10.
Elle interdit l'écriture manuelle d'un `id`, ce qui empêche la désynchronisation de la séquence.

BIGSERIAL n'est qu'un DEFAULT nextval(...), donc contournable et un jour nextval renvoie une valeur déjà prise.

Sur beaucoup de projets, les clés primaires sont encore des INT. En soi, ce n’est pas dramatique : tout dépend de la volumétrie et de la charge réelles, et la grande majorité des applications ne sont ni Netflix ni Google. Le piège est ailleurs. Dès qu’une table sert à historiser ou à journaliser une entité qui subit beaucoup de mises à jour, les 2,1 milliards arrivent bien plus vite qu’on ne l’imagine.

C’est exactement ce qui est arrivé chez mon client Stonal : il a fallu basculer d’INT à BIGINT.
La migration s’est bien passée, mais elle a été longue.

  • Création d’une nouvelle colonne _id.
  • Backfill des nouveaux identifiants, pour repartir d’une séquence cohérente.
  • Création puis backfill des colonnes correspondantes dans chaque clé étrangère.
  • Et l’étape la plus risquée : le switch, avec désactivation des contraintes, renommage des colonnes, réactivation des contraintes.

Vous l’aurez compris : la migration s’est étalée sur plusieurs livraisons de scripts. La morale tient en une ligne :

Quand vous créez une table avec un identifiant séquentiel, à moins d’être certain qu’elle ne grossira jamais (une table de référentiel, typiquement), prenez un BIGINT.

Ses vraies limites, celles qui poussent à chercher autre chose :

  • La coordination.
    La valeur vient de la base. L’application ne connaît son identifiant qu’après l’INSERT (RETURNING id). Écrire un graphe d’objets liés en un seul aller-retour devient pénible, et l’écriture offline-first impossible.
  • La fusion de données.
    Deux bases avec les mêmes plages d’id ne se fusionnent pas sans réécrire toutes les clés étrangères. Même problème pour du sharding, ou pour restaurer un sous-ensemble de lignes depuis un backup dans une base vive.
  • La fuite d’information.
    /invoices/1042 dit à un concurrent que vous avez émis 1042 factures, et deux commandes passées à une semaine d’intervalle révèlent votre volume hebdomadaire. C’est le problème des chars allemands, appliqué à votre chiffre d’affaires.
  • L’énumération.
    Un identifiant devinable transforme le moindre oubli de contrôle d’accès en fuite de masse : il suffit d’incrémenter. Ce n’est pas un défaut de l’entier car l’autorisation reste la vraie réponse mais c’est un facteur d’amplification bien réel.

Un mythe à corriger

Une séquence PostgreSQL n'est pas transactionnelle : un ROLLBACK ne rend pas le numéro consommé. Vos id auront donc des trous, et c'est normal. Ne construisez jamais une numérotation métier continue (numéros de facture, mentions légales) sur une séquence : il faut une table de compteurs verrouillée, ou un calcul a posteriori.

4. L’UUID v4 : le piège de l’aléatoire

gen_random_uuid() produit un UUID version 4 : 122 bits aléatoires. Unique partout, sans coordination, imprévisible. Et c’est précisément l’aléatoire qui pose problème quand la valeur devient une clé d’index.

Le tableau de la section 2 le montre : +27 % d’index, +21 % de WAL, la moitié des feuilles fragmentées par rapport à un UUID trié. Et le pire est ailleurs :

  • La localité des écritures disparaît. Avec une clé croissante, les lignes écrites au même moment vivent dans les mêmes pages : un checkpoint réécrit peu de pages, le cache est efficace. Avec de l’aléatoire, N insertions touchent N pages différentes, dispersées dans l’index.
  • Les full-page images se multiplient. Après un checkpoint, la première modification d’une page journalise la page entière (8 Ko) dans le WAL. Toucher beaucoup de pages distinctes, c’est beaucoup de FPI — c’est l’origine principale du surcoût de WAL mesuré.
  • La localité des lectures aussi. « Les 100 dernières commandes » est un Index Scan séquentiel avec une clé triée, et 100 accès dispersés avec un UUIDv4.

En contrepartie, UUIDv4 offre une propriété que les identifiants triés n’ont pas : il ne révèle rien. Pas de date de création, pas d’ordre, pas de volume. Pour un jeton de partage public, une URL de réinitialisation, une ressource dont l’existence même est une information, c’est le bon choix mais ce sont des jetons, pas nécessairement des clés primaires.

Un UUIDv4 par ligne, ce n’est pas un problème. Un UUIDv4 comme clé de tri d’un index de 200 Go alimenté à 5 000 insertions par seconde, c’en est un.


5. Les identifiants triés dans le temps

L’idée est simple : mettre l’horodatage dans les bits de poids fort, et l’aléatoire dans les bits de poids faible.
On obtient un identifiant globalement unique, générable côté client, et croissant donc compatible avec l’optimisation de page à droite du B-tree.

Trois implémentations dominent.

UUIDv7 — le nouveau défaut

Standardisé par la RFC 9562 (2024) qui déprécie l’ancienne standardisation de l’UUID, la RFC 4122, UUIDv7 est un UUID de 128 bits dont les 48 premiers bits sont un timestamp Unix en millisecondes, le reste étant de l’aléatoire (PostgreSQL y place en plus 12 bits de fraction de milliseconde, ce qui rend les identifiants créés dans la même milliseconde ordonnés entre eux).

Depuis PostgreSQL 18, c’est une fonction native donc plus besoin d’extension ni de génération applicative :

CREATE TABLE orders (
    id UUID PRIMARY KEY DEFAULT uuidv7(),
    ...
);

SELECT uuid_extract_timestamp(id), uuid_extract_version(id) FROM orders LIMIT 1;
--    2026-08-02 21:12:50.551+00  |  7

C’est un uuid standard : mêmes 16 octets, même type, même sérialisation. Tout ce qui sait lire un UUID sait lire un UUIDv7, sans changement de driver ni d’ORM. Sur une base antérieure à la 18, l’extension pg_uuidv7 fournit la même fonction.
La génération peut aussi se faire côté applicatif :

id, err := uuid.NewV7() // github.com/google/uuid
let id = Uuid::now_v7(); // crate uuid, feature "v7"
UUID id = UuidCreator.getTimeOrderedEpoch(); // com.github.f4b6a3:uuid-creator

ULID — la même idée, un autre encodage

Un ULID, ce sont 128 bits également : 48 bits de timestamp et 80 bits d’aléatoire, mais encodés en Crockford base32 sur 26 caractères (01ARZ3NDEKTSV4RRFFQ69G5FAV) — plus courts et plus lisibles qu’un UUID canonique, sans tiret, sans caractère ambigu, sûrs dans une URL.

Techniquement, ULID et UUIDv7 résolvent le même problème avec le même layout.
La différence est un encodage textuel, pas une structure.

Le piège est là : PostgreSQL n’a pas de type ULID, et la tentation est de créer une colonne text de 26 caractères. La dernière ligne du tableau de la section 2 dit ce que ça coûte.

Un ULID fait 128 bits :

  • stockez-le dans une colonne uuid (la conversion base32 ↔ hexadécimal est bijective) ,
  • ne faites du base32 que dans la couche de présentation.

Autrement dit :

si vous vouliez un ULID, prenez un UUIDv7 stocké en uuid, et affichez-le comme vous voulez.
Vous gardez le type natif, les index, les outils, et le support de tous les drivers.

TSID — quand 64 bits suffisent

Le TSID (Time-Sorted Identifier, popularisé par Vlad Mihalcea) prend le contre-pied : 64 bits seulement avec 42 bits de timestamp en millisecondes (~139 ans) et 22 bits partagés entre identifiant de nœud et compteur aléatoire.

L’intérêt : il tient dans un bigint. On garde :

  • la génération côté client,
  • l’ordre temporel,
  • l’absence de coordination avec la base ,
  • le coût de stockage d’un entier séquentiel.

C’est le meilleur ratio du lot.

Le prix à payer: l’unicité n’est plus statistique mais configurée.
22 bits par milliseconde, ce n’est pas infini ; il faut attribuer un identifiant de nœud distinct à chaque générateur (variable d’environnement, index de StatefulSet, ID d’instance). Un déploiement où deux pods partagent le même nœud logique produit des collisions, et une collision sur une clé primaire, c’est une erreur en production.

👉 C’est un vrai coût opérationnel, à peser contre 8 octets par ligne.


6. Le compromis hybride : clé interne, identifiant public

Il existe une troisième voie, souvent la plus pragmatique sur une base existante :

garder un bigint comme clé primaire technique et exposer un identifiant opaque.

Aparté : clé de substitution et clé naturelle

Une clé de substitution (surrogate key) est un identifiant sans aucun sens métier, inventé pour désigner une ligne et rien d'autre : un bigint auto-incrémenté, un UUID, un TSID. Elle s'oppose à la clé naturelle (business key), une donnée qui identifie déjà l'objet dans le monde réel : un email, un SIRET, un ISBN, un numéro de commande.

L'intérêt de la première, c'est qu'elle ne change jamais. Une clé naturelle, si : un email se corrige, un SIRET se réattribue, un numéro de commande change de format le jour où le service commercial refait sa nomenclature. Et une clé primaire qui change doit être propagée dans chaque table qui la référence. D'où la règle de la section 7 : clé de substitution en PRIMARY KEY, clé métier en UNIQUE.

Attention à ne pas confondre cet axe avec celui de cette section. Ici, id et public_id sont tous les deux des clés de substitution : aucun des deux ne porte de sens métier. Ce qui les distingue n'est pas leur nature mais leur exposition, l'un ne sortant jamais de la base quand l'autre est fait pour être publié. Ce second pattern n'a pas de nom canonique ; on le croise sous « public ID », « external ID » ou « identifiant opaque » — les cus_... de Stripe, ou le node_id de GitHub à côté de son id numérique.

CREATE TABLE customers (
    id        BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    public_id UUID NOT NULL DEFAULT uuidv7(),
    ...
);

CREATE UNIQUE INDEX customers_public_id_key ON customers (public_id);

Les clés étrangères, les jointures et les index internes utilisent id (8 octets, compact, rapide). Les URLs, les APIs, les webhooks et les exports utilisent public_id. Aucune information ne fuit, et le cœur de la base garde ses performances.

Ce que ça coûte, honnêtement :

  • Un index unique supplémentaire à maintenir sur chaque table concernée, sur une colonne aléatoire ou semi-aléatoire.
  • Une indirection permanente : chaque requête entrante résout d’abord public_id → id. C’est un accès index, donc rapide, mais c’est du code à écrire partout et une source d’erreurs (le jour où un id interne finit dans une réponse d’API, la fuite est silencieuse).
  • Deux identifiants pour un même objet dans les logs, les tickets de support et les conversations. Le coût est humain plus que technique, mais il est réel.

C’est un bon choix quand la base existe déjà en bigint et qu’on veut arrêter d’exposer des identifiants énumérables.
C’est un choix plus discutable sur un schéma neuf, où uuidv7() en clé primaire donne le même bénéfice sans indirection.

Si je vous en parle, c’est parce que c’est la solution que nous avons choisie chez mon client, Stonal 🫠

A une différence près, l’id public est un UUID v4.


7. Choisir

Besoin Choix
Monolithe, base unique, identifiants non exposés BIGINT identity
Schéma neuf, API publique, microservices UUID + uuidv7()
Génération côté client indispensable, volumétrie énorme TSID en bigint
Base bigint existante à ne pas migrer bigint + public_id UUID
Jeton public, non énumérable, non datable UUID + gen_random_uuid() (v4)

Et quelques règles qui ne dépendent pas du contexte :

  • Éviter un UUID en text / varchar(36) / char(36). Le type uuid existe, il fait 16 octets contre 37, il compare en binaire.
  • Éviter l’UUIDv4 comme clé primaire d’une table à forte volumétrie d’écriture. UUIDv7 offre les mêmes garanties sans la fragmentation.
  • Éviter les clés naturelles (email, SIRET, numéro de commande) comme clé primaire. Les données métier changent (un email se corrige, un SIRET se réattribue) et une clé primaire qui change doit être propagée dans toutes les tables qui la référencent. Clé technique en PRIMARY KEY, clé métier en UNIQUE.
  • Privilégier GENERATED ALWAYS AS IDENTITY à SERIAL/BIGSERIAL sur un schéma neuf. Cela fait la même chose sans le piège de la séquence désynchronisée.
  • Attention à ce que révèle un identifiant trié. UUIDv7 contient sa date de création en clair (uuid_extract_timestamp le prouve) et permet de deviner l’ordre de création. Ce n’est presque jamais un problème sauf quand la date de création est elle-même l’information sensible.

⚠️ Migrer une clé primaire

Passer de bigint à uuid sur une table en production n'est pas un ALTER TABLE.
C'est un chantier de plusieurs semaines sur un schéma mature, d'où l'intérêt de choisir correctement au début, ou d'assumer l'approche hybride de la section 6.

En résumé

  • La clé primaire coûte des octets et un ordre d’insertion. Le second est souvent plus cher que le premier.
  • L’aléatoire pur (UUIDv4) fragmente les index.
  • UUIDv7 supprime ce surcoût en gardant les propriétés de l’UUID, et il est natif depuis PostgreSQL 18 (uuidv7()).
  • ULID et UUIDv7 sont la même idée avec un encodage différent : stockez-les en uuid, jamais en text.
  • TSID est l’option 64 bits, la plus compacte, au prix d’une gestion d’identifiants de nœuds.
  • Le bigint séquentiel reste le meilleur choix technique pur, et le pire choix pour un identifiant exposé publiquement.

La bonne question n’est pas « entier ou UUID ? » mais « qui génère l’identifiant, et qui a le droit de le voir ? »

Ressources


Mesures réalisées sur PostgreSQL 18.4 (image postgres:18.4), configuration par défaut, 2 000 000 de lignes par table.