Québec, Canada

403-1381 1re Avenue

+1 581.849.27.96

bdgouthiere@gmail.com

Go 1.27 génère enfin des UUID : lequel mettre dans ta clé primaire PostgreSQL ?

Ou : Comment choisir entre un nombre tiré au sort et un nombre qui donne l’heure

Depuis le 19 août 2026, Go sait fabriquer un UUID sans dépendance externe : la version 1.27 a ajouté un paquet uuid à la bibliothèque standard1. Il tient en une poignée de fonctions, et la plus simple, uuid.New(), renvoie un UUID version 4, c’est-à-dire 122 bits de hasard. Pour un identifiant de requête ou un nom de fichier, la question s’arrête là. Pour la clé primaire d’une table PostgreSQL, elle commence : le paquet propose aussi uuid.NewV7(), un identifiant horodaté qui se range dans l’ordre de création, et tout le monde a lu quelque part qu’un index B-tree le préfère. J’ai voulu savoir de combien, sur 5 millions de lignes et PostgreSQL 18. La réponse tient en un tableau. Elle a aussi une contrepartie, que l’index ne montre pas.

L’article sur les méthodes génériques de Go 1.27 annonçait celui-ci. Commençons par le paquet lui-même, parce que son choix par défaut n’est pas un hasard.

UUID v4 ou v7 en clé primaire PostgreSQL : ce que mesure un index de 5 millions de lignes

Huit ans de discussion pour sept fonctions

Une première proposition d’UUID dans la bibliothèque standard a été déposée en février 2018 et refermée trois semaines plus tard : les paquets tiers suffisaient. La seconde, ouverte en août 2023, a accumulé plus de 340 commentaires avant d’être acceptée le 8 avril 20261. Entre les deux, github.com/google/uuid était devenu l’une des dépendances les plus importées de l’écosystème, avec 112 000 paquets importateurs selon le décompte cité dans la discussion2.

Le résultat est volontairement minuscule : sept fonctions, New, NewV4, NewV7, Parse, MustParse, Nil et Max, plus une méthode Compare et les conversions en texte. Pas de v1, de v3 ni de v5, et aucune fonction pour lire la version ou l’horodatage d’un UUID existant. Le comité a regardé les usages réels de google/uuid : 94 % des utilisateurs génèrent un v4, 4 % un v1, 1 % un v7.

Le choix de v4 par défaut est argumenté noir sur blanc. La note de conception reconnaît l’avantage de v7 dans un B-tree, puis rappelle qu’un v7 crée un point chaud dans les bases réparties sur plusieurs nœuds, comme Spanner ou CockroachDB, et qu’il contient un horodatage dont l’exposition « may be a security issue »2. Un v4 n’est qu’un grand nombre aléatoire : c’est le défaut le plus sûr. Celui qui veut v7 l’écrit.

Autre détail qui change la vie : database/sql connaît le nouveau type, comme il connaît time.Time. Plus besoin d’écrire un Valuer ni un Scanner. J’ai vérifié l’aller-retour sous Go 1.27.1 avec deux pilotes, pgx 5.11 via database/sql et lib/pq 1.12 :

1
2
3
4
5
6
id := uuid.NewV7()
// database/sql convertit uuid.UUID tout seul, sans Valuer à écrire
_, err := db.Exec(`INSERT INTO commandes (id) VALUES ($1)`, id)

var relu uuid.UUID
err = db.QueryRow(`SELECT id FROM commandes WHERE id = $1`, id).Scan(&relu)

Ce que v4 fait à un B-tree

Un index B-tree est un classeur trié. Avec une clé croissante, chaque nouvelle fiche va à la fin du dernier tiroir. Avec une clé aléatoire, elle va n’importe où, et le tiroir qui la reçoit est souvent plein : PostgreSQL le coupe en deux, et chaque moitié reste à demi vide jusqu’à ce que le hasard la remplisse. La documentation le dit à sa façon : les feuilles sont remplies à 90 % quand l’index s’étend vers la droite, puis découpées, « leading to fragmentation », quand elles débordent3. Un v7 commence par 48 bits de millisecondes : il arrive toujours à droite.

J’ai inséré 5 millions de lignes dans une table à deux colonnes, par transactions de 10 000 lignes, sur PostgreSQL 18.6 réglé par défaut (128 Mo de cache), avec un point de contrôle (checkpoint) forcé à mi-parcours, comme il en survient en production. Deux passes par cas4.

Clé primaireDuréeTaille de l’indexRemplissage des feuillesJournal (WAL) écritPages d’index lues hors du cache
bigint (identité)8,5 s107 Mo90 %458 à 535 Mo1
UUID v7 (uuid.NewV7)8,4 s150 Mo90 %525 à 559 Mo1
UUID v4 (uuid.NewV4)20,1 à 20,9 s192 à 194 Mo70 %664 Mo321 821 à 328 535

L’UUID v7 se comporte comme un bigint dont la clé pèserait deux fois plus lourd : même remplissage, même durée, un index plus gros d’environ 8 octets par entrée. Le v4, lui, grossit l’index de 28 %, écrit environ 20 % de journal en plus, et met 2,4 fois plus de temps. La dernière colonne éclaire la durée : l’index du v4 ne tient plus dans les 128 Mo de cache, et PostgreSQL va chercher plus de 320 000 pages ailleurs. Celui du v7 n’en a jamais besoin, puisque la seule page qui compte est la dernière. PostgreSQL garde même cette page en mémoire d’une insertion à l’autre, un raccourci ajouté en 2018 pour les clés croissantes : une page lue par ligne, contre trois pour descendre l’arbre avec un v4.

Deux nuances, parce qu’un tableau ne dit pas tout. À 1 million de lignes, quand l’index tient encore en cache, l’écart tombe à 3,06 secondes contre 2,93 : sur une petite table, v4 ne coûte presque rien. Et lors d’un premier essai, où PostgreSQL a déclenché seul son point de contrôle au milieu de l’insertion, le v4 a mis 38 secondes, le v7 toujours 8,4. Le coût de v4 n’est pas une constante : il grandit avec la table et dépend de ce que fait le serveur à côté.

uuidv7() côté PostgreSQL ou NewV7() côté Go

PostgreSQL 18, sorti le 25 septembre 2025, fournit sa propre fonction uuidv7()5. Les deux implémentations suivent la même méthode de la RFC 9562 : 12 bits de fraction de milliseconde après l’horodatage, pour garder l’ordre même quand plusieurs UUID naissent dans la même milliseconde. Et les deux limitent leur promesse au même périmètre : l’ordre est garanti dans un processus Go, ou dans une connexion PostgreSQL, pas entre plusieurs serveurs dont les horloges divergent de quelques millisecondes. Pour un index, ce désordre de quelques millisecondes reste cantonné aux dernières pages.

Le choix se fait donc ailleurs. Générer en Go donne l’identifiant avant l’insertion : tu peux le renvoyer au client, le mettre dans un message ou lier des lignes enfants sans attendre un RETURNING. Une valeur par défaut DEFAULT uuidv7() couvre au contraire toutes les écritures, y compris celles qui ne passent pas par ton code. Un piège seulement : COPY renonce à l’insertion par lots dès qu’une colonne a une valeur par défaut volatile, ce qui est le cas de uuidv7() comme de gen_random_uuid()6. Pour un chargement massif, mieux vaut fournir les identifiants.

L’horodatage que tout le monde sait lire

Le paquet uuid refuse de lire l’heure d’un v7, par choix : la discussion opposait ceux qui voulaient cette fonction à ceux qui jugeaient toute lecture de l’horodatage comme une erreur, et le comité a tranché en ne l’écrivant pas2. Ça ne protège personne. Six octets suffisent :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// V7Time lit l'horodatage d'un UUID v7 : les 48 premiers bits,
// en millisecondes depuis 1970.
func V7Time(u uuid.UUID) (time.Time, bool) {
	if u[6]>>4 != 7 { // le quartet de version
		return time.Time{}, false
	}
	var b [8]byte
	copy(b[2:], u[:6])
	return time.UnixMilli(int64(binary.BigEndian.Uint64(b[:]))), true
}

PostgreSQL le fait d’office avec uuid_extract_timestamp(), y compris sur un UUID généré par Go :

1
2
3
4
uuidbench=# SELECT uuid_extract_timestamp('01a146fa-5fc4-7ce9-98d3-a656d303f2b0');
   uuid_extract_timestamp
----------------------------
 2026-10-16 23:09:36.068+00

Si cet identifiant apparaît dans une URL, n’importe qui sait à la milliseconde près quand la ligne a été créée. La RFC 9562 parle d’une « very small attack surface » et rappelle qu’un UUID ne doit jamais servir de jeton d’accès7. Petite, mais réelle : la date d’inscription d’un client, l’heure d’un dossier médical ou le rythme des commandes d’une boutique sont parfois des informations qu’on ne publie pas exprès. Un bigint trahit d’ailleurs autre chose, le volume : la commande 48 213 dit combien d’autres l’ont précédée. Le v7 ne livre que l’heure.

DevOps Dave : J’ai tout passé en uuid.NewV7(). Index compact, insertions rapides, et c’est dans la bibliothèque standard.

Security Sarah : L’identifiant du compte client, il apparaît dans l’URL du profil ?

DevOps Dave : Oui, pourquoi ?

Security Sarah : Parce que n’importe qui peut maintenant lire la date d’inscription de chaque client.

La sortie dépend de ce que tu exposes. Pour une table dont les identifiants ne quittent jamais l’application, v7 sans hésiter. Pour une table dont l’identifiant finit dans une URL alors que l’heure de création ne regarde personne, garde v4, ou ajoute à côté de la clé v7 un identifiant public v4. Ce second identifiant aura son propre index, aléatoire celui-là : tu repaies une partie du coût, mais seulement sur la colonne que le public voit.

Tout le code de cet article a été testé sous Go 1.27.1, go vet et le détecteur de courses compris : tri croissant de 200 000 v7 successifs, absence de doublon sur huit goroutines, aller-retour avec les deux pilotes, et lecture du même horodatage par Go et par PostgreSQL. Le paquet a choisi le nombre tiré au sort par défaut, et il a eu raison : c’est le seul qui ne dit rien. Pour une clé primaire, choisis celui qui donne l’heure, en sachant à qui tu la donnes.



  1. Les notes de version de Go 1.27 annoncent que « The new uuid package generates and parses UUIDs », et la page des versions date Go 1.27.0 du 19 août 2026. La proposition de 2018 a été ouverte le 12 février et fermée le 5 mars 2018. La proposition actuelle, ouverte le 14 août 2023, demandait au départ les versions 3, 4 et 5 ; le comité l’a acceptée le 8 avril 2026 avec v4 et v7, sous le nom uuid plutôt que crypto/uuid, d’abord envisagé. ↩︎ ↩︎

  2. La note de conception de Damien Neil, publiée le jour de l’acceptation, donne les chiffres d’usage, les 112 000 importateurs de google/uuid et les raisons du choix de v4 : « UUIDv7 can cause hotspotting and a performance bottleneck when used for inserting large numbers of entries into horizontally-sharded databases ». Elle explique aussi l’absence de fonction pour lire l’horodatage. La prise en charge par database/sql figure dans la documentation de Rows.Scan : un pilote qui ignore le type reçoit et renvoie la forme texte. ↩︎ ↩︎ ↩︎

  3. La documentation de CREATE INDEX, paramètre fillfactor : « B-trees use a default fillfactor of 90 ». Le raccourci vers la page la plus à droite est décrit dans nbtinsert.c, qui précise qu’il « helps with indexes on an auto-incremented field » ; il a été intégré en mars 2018, pour PostgreSQL 11. Dans mes mesures, l’arbre avait trois niveaux, et les compteurs de PostgreSQL montrent environ un accès de page par insertion pour le v7, contre trois pour le v4. ↩︎

  4. Conditions : portable AMD Ryzen 7 5800H avec disque NVMe, PostgreSQL 18.6 dans un conteneur Docker jetable (image Alpine officielle), réglages par défaut (shared_buffers à 128 Mo, max_wal_size à 1 Go), chargement par COPY via pgx 5.11. Table bench (id, note) avec une note de huit caractères. La durée exclut la génération des identifiants et le point de contrôle forcé. Exclure la génération n’est pas un détail : sur cette machine, le noyau lit l’heure par l’horloge HPET, time.Now() coûte 2,9 µs, et NewV7 en coûte 3,7 contre 0,7 pour NewV4. C’est le prix de l’horloge de mon portable, pas celui du paquet. Pour automatiser ce genre de base jetable dans des tests, voir les tests d’intégration Go avec Docker. ↩︎

  5. L’annonce de PostgreSQL 18, datée du 25 septembre 2025 : « PostgreSQL 18 also adds UUIDv7 generation through the uuidv7() function ». La documentation des fonctions UUID décrit aussi un paramètre optionnel qui décale l’horodatage d’un intervalle donné. Un décalage constant cache l’heure absolue à qui ne le connaît pas, mais l’écart entre deux identifiants reste exact. ↩︎

  6. Dans copyfrom.c, PostgreSQL passe en insertion ligne à ligne quand « there are any volatile default expressions in the table ». nextval(), qui alimente les colonnes d’identité, est la seule exception. Dans mes essais, le même chargement par COPY avec DEFAULT uuidv7() a écrit 742 Mo de journal, contre 525 à 559 Mo avec des identifiants fournis par Go. ↩︎

  7. La RFC 9562, publiée en mai 2024, remplace la RFC 4122 et définit la version 7. Sa section 8 écrit que « Timestamps embedded in the UUID do pose a very small attack surface » et que les UUID « MUST NOT be used as security capabilities ». Elle recommande aussi de préférer v7 à v1 et v6, et sa section 2.1 note que les versions non ordonnées dans le temps, comme v4, « have poor database-index locality ». ↩︎