UUID vs ULID vs ID seriale: scopri come ciascuno influisce su indicizzazione, ordinamento, sharding e su esportazione/importazione dati in progetti reali.

La scelta di un ID sembra noiosa nella prima settimana. Poi rilasci, i dati crescono e quella decisione "semplice" appare ovunque: indici, URL, log, esportazioni e integrazioni.
La vera domanda non è "qual è il migliore?" ma "quale dolore voglio evitare in futuro?" Gli ID sono difficili da cambiare perché vengono copiati in altre tabelle, memorizzati nella cache dai client e usati da altri sistemi.
Quando l'ID non si allinea all'evoluzione del prodotto, di solito lo noti in alcuni punti:
C'è sempre un compromesso tra comodità ora e flessibilità dopo. Gli interi seriali sono facili da leggere e spesso veloci, ma possono rivelare il numero di record e rendere più difficile unire dataset. Gli UUID casuali sono ottimi per l'unicità tra sistemi, ma sono peggiori per gli indici e meno leggibili per le persone. Gli ULID puntano all'unicità globale con un ordinamento "tempo-like", ma hanno comunque considerazioni su storage e tooling.
Un modo utile di pensarci: per chi è principalmente l'ID?
Se l'ID è soprattutto per le persone (support, debugging, ops), vinceranno soluzioni più corte e facilmente leggibili. Se è per macchine (scritture distribuite, client offline, sistemi multi-regione), l'unicità globale e l'assenza di collisioni contano di più.
Quando la gente discute "UUID vs ULID vs serial IDs", sta davvero scegliendo come ogni riga ottiene un'etichetta univoca. Quell'etichetta influisce su quanto sia semplice inserire, ordinare, unire e spostare i dati in seguito.
Un ID seriale è un contatore. Il database assegna 1, poi 2, poi 3, e così via (spesso memorizzato come integer o bigint). È facile da leggere, economico in termini di storage e di solito veloce perché le nuove righe finiscono alla fine dell'indice.
Un UUID è un identificatore a 128-bit che sembra casuale, come 3f8a.... Nella maggior parte degli scenari può essere generato senza chiedere al database il numero successivo, quindi diversi sistemi possono creare ID indipendentemente. Il compromesso è che gli insert dall'aspetto casuale possono far lavorare di più gli indici e occupare più spazio rispetto a un semplice bigint.
Un ULID è anch'esso a 128-bit, ma è progettato per essere approssimativamente ordinabile per tempo. I ULID più recenti di solito vengono dopo quelli più vecchi, pur restando globalmente unici. Ottieni in genere alcuni dei benefici del "generabile ovunque" degli UUID con un comportamento di ordinamento più amichevole.
Un riassunto semplice:
Gli ID seriali sono comuni per app con un singolo database e strumenti interni. Gli UUID compaiono quando i dati vengono creati attraverso più servizi, dispositivi o regioni. Gli ULID sono popolari quando i team vogliono generazione distribuita degli ID ma tengono all'ordinamento per scopi di paginazione o query "più recenti prima".
Una primary key è di solito supportata da un indice (spesso un B-tree). Pensa a quell'indice come un elenco telefonico ordinato: ogni nuova riga ha bisogno di una voce posta nel punto giusto per mantenere veloci le ricerche.
Con ID casuali (UUIDv4 classici), le nuove voci finiscono in punti sparsi dell'indice. Questo significa che il database tocca molte pagine dell'indice, effettua più split di pagina e compie scritture extra. Col tempo ottieni più churn dell'indice: più lavoro per insert, più cache miss e indici più grandi del previsto.
Con ID per lo più incrementali (serial/bigint, o ID ordinabili per tempo come molti ULID), il database può solitamente apporre nuove voci vicino alla fine dell'indice. Questo è più cache-friendly perché le pagine recenti restano "hot", e gli insert tendono a essere più fluidi a tassi di scrittura elevati.
La dimensione della chiave conta perché le voci dell'indice non sono gratuite:
Chiavi più grandi significano meno voci per pagina di indice. Questo spesso porta a indici più profondi, più pagine lette per query e più RAM necessaria per mantenere le prestazioni.
Se hai una tabella di "events" con insert costanti, una primary key random UUID può iniziare a sembrare più lenta prima rispetto a una chiave bigint, anche se i lookup a singola riga restano accettabili. Se prevedi scritture pesanti, il costo dell'indicizzazione è solitamente la prima differenza reale che noterai.
Se hai costruito "Carica altro" o infinite scroll, conosci già il problema degli ID che non si ordinano bene. Un ID si "ordina bene" quando ordinarlo dà un ordine stabile e significativo (spesso il tempo di creazione), così la paginazione è prevedibile.
Con ID casuali (come UUIDv4), le righe più recenti sono disperse. Ordinare per id non corrisponde al tempo e la paginazione a cursore tipo "dammi elementi dopo questo id" diventa inaffidabile. Di solito si ricorre a created_at, che va bene, ma bisogna usarlo con attenzione.
Gli ULID sono progettati per essere approssimativamente ordinati per tempo. Se ordini per ULID (come stringa o in forma binaria), gli elementi più recenti tendono a comparire dopo quelli più vecchi. Questo rende la paginazione con cursore più semplice perché il cursore può essere l'ultimo ULID visto.
ULID aiuta con un ordinamento naturale "time-ish" per feed, cursori più semplici e meno inserimenti casuali rispetto a UUIDv4.
Ma ULID non garantisce un ordine temporale perfetto quando molti ID vengono generati nella stessa milliseconda su più macchine. Se ti serve l'ordinamento esatto, vuoi ancora un vero timestamp.
created_at è ancora meglioOrdinare per created_at è spesso più sicuro quando fai backfill di dati, importi record storici o hai bisogno di tie-breaking chiari.
Un pattern pratico è ordinare per (created_at, id), dove id è solo un tie-breaker.
Sharding significa dividere un database in più database più piccoli in modo che ogni shard contenga una parte dei dati. Di solito le squadre lo fanno più avanti, quando un singolo database è difficile da scalare o diventa un punto singolo di errore.
La scelta dell'ID può rendere lo sharding gestibile o doloroso.
Con ID sequenziali (auto-increment serial o bigint), ogni shard genererà volentieri 1, 2, 3.... Lo stesso ID può esistere su più shard. La prima volta che devi unire dati, spostare righe o costruire funzionalità cross-shard, affronti collisioni.
Puoi evitare collisioni con coordinazione (un servizio ID centrale, o range per shard), ma questo aggiunge componenti e può diventare un collo di bottiglia.
UUID e ULID riducono la coordinazione perché ogni shard può generare ID indipendentemente con un rischio di duplicati estremamente basso. Se pensi che un giorno dividerai i dati tra database, questo è uno dei motivi più forti contro le sole sequenze.
Un compromesso comune è aggiungere un prefisso di shard e poi usare una sequenza locale su ogni shard. Puoi memorizzarlo come due colonne, o impacchettarlo in un unico valore.
Funziona, ma crea un formato ID personalizzato. Ogni integrazione deve capirlo, l'ordinamento smette di rappresentare l'ordine temporale globale senza logica aggiuntiva, e spostare dati tra shard può richiedere la riscrittura degli ID (che rompe riferimenti se quegli ID sono condivisi).
Fatti una domanda presto: avrai mai bisogno di combinare dati da più database e mantenere i riferimenti stabili? Se sì, pianifica ID globalmente unici fin dal giorno uno, o prevedi il budget per una migrazione in seguito.
L'esportazione e l'importazione è il punto in cui la scelta dell'ID smette di essere teorica. Nel momento in cui cloni prod in staging, ripristini un backup o unisci dati da due sistemi, scopri se i tuoi ID sono stabili e portabili.
Con ID seriali (auto-increment), di solito non puoi riprodurre insert in un altro database aspettandoti che i riferimenti rimangano intatti a meno che non preservi i numeri originali. Se importi solo un sottoinsieme di righe (per esempio 200 clienti e i loro ordini), devi caricare le tabelle nell'ordine giusto e mantenere le primary key esatte. Se qualcosa viene rinumerato, le foreign key si rompono.
UUID e ULID vengono generati fuori dalla sequenza del database, quindi sono più facili da spostare tra ambienti. Puoi copiare righe, mantenere gli ID e le relazioni restano coerenti. Questo aiuta quando ripristini backup, fai esportazioni parziali o unisci dataset.
Esempio: esporti 50 account da produzione per debuggare un problema in staging. Con chiavi primarie UUID/ULID puoi importare quegli account più le righe correlate (progetti, fatture, log) e tutto punta ancora al genitore giusto. Con ID seriali spesso finisci a costruire una tabella di traduzione (old_id -> new_id) e a riscrivere le foreign key durante l'import.
Per import bulk, le basi contano più del tipo di ID:
Puoi prendere una decisione solida rapidamente se ti concentri su cosa farà male dopo.
Scrivi i rischi futuri principali. Eventi concreti aiutano: dividere in più database, unire dati da un altro sistema, scritture offline, copie frequenti dei dati tra ambienti.
Decidi se l'ordinamento dell'ID deve corrispondere al tempo. Se vuoi "più recenti prima" senza colonne aggiuntive, ULID (o un ID ordinabile per tempo) è una buona scelta. Se ti basta ordinare per created_at, UUID e serial funzionano entrambi.
Stima il volume di scrittura e la sensibilità dell'indice. Se prevedi molti insert e il tuo indice primario è quello preso di mira, un serial BIGINT è di solito più leggero sugli indici B-tree. Gli UUID casuali tendono a causare più churn.
Scegli un default, poi documenta le eccezioni. Mantieni semplice: un default per la maggior parte delle tabelle e una regola chiara per quando deviare (spesso: ID pubblici vs ID interni).
Lascia spazio al cambiamento. Evita di codificare significato negli ID, decidi dove vengono generati (DB vs app) e mantieni i vincoli espliciti.
La trappola più grande è scegliere un ID perché è popolare e poi scoprire che confligge con come fai query, scala o condividi i dati. La maggior parte dei problemi appare mesi dopo.
Fallimenti comuni:
123, 124, 125, le persone possono indovinare record vicini e sondare il sistema.Segnali di avvertimento da affrontare presto:
Scegli un tipo di primary key e mantienilo coerente nella maggior parte delle tabelle. Mischiare tipi (bigint in un posto, UUID in un altro) rende join, API e migrazioni più complicati.
Stima la dimensione dell'indice alla scala prevista. Chiavi più larghe significano indici primari più grandi e più memoria/IO.
Decidi come farai la paginazione. Se paginate per ID, assicurati che l'ID abbia un ordinamento prevedibile (o accetta che non lo abbia). Se paginate per timestamp, indicizza created_at e usalo coerentemente.
Testa il tuo piano di import su dati simili alla produzione. Verifica che puoi ricreare record senza rompere le foreign key e che le re-importazioni non generino ID nuovi in modo silenzioso.
Scrivi la strategia per le collisioni. Chi genera l'ID (DB o app) e cosa succede se due sistemi creano record offline e poi fanno sync?
Assicurati che URL pubblici e log non perdano pattern che vuoi mantenere (conteggio record, tasso di creazione, indizi di shard interni). Se usi ID seriali, dai per scontato che le persone possano indovinare ID vicini.
Un founder lancia un CRM semplice: contatti, offerte, note. Un database Postgres, una web app e l'obiettivo principale è spedire velocemente.
All'inizio, una primary key serial bigint sembra perfetta. Gli insert sono veloci, gli indici rimangono ordinati ed è facile leggere i log.
Un anno dopo, un cliente chiede esportazioni trimestrali per un audit e il founder inizia a importare lead da uno strumento marketing. Gli ID che erano solo interni ora compaiono in CSV, email e ticket di supporto. Se due sistemi usano entrambi 1, 2, 3..., le fusioni diventano complicate. Finisci per aggiungere colonne di origine, tabelle di mapping o riscrivere gli ID durante l'import.
Dopo due anni arriva un'app mobile. Deve creare record offline e poi sincronizzare. Ora servono ID che possono essere generati sul client senza parlare col database, con basso rischio di collisione quando i dati arrivano in ambienti diversi.
Un compromesso che spesso regge nel tempo:
Se sei indeciso tra UUID, ULID e serial, decidi in base a come i tuoi dati si muoveranno e cresceranno.
Scelte in una frase per casi comuni:
bigint serial.Mischiare è spesso la scelta migliore. Usa bigint serial per tabelle interne che non lasciano mai il database (tabelle di join, job background), e usa UUID/ULID per entità pubbliche come utenti, organizzazioni, fatture e tutto ciò che potresti esportare, sincronizzare o referenziare da un altro servizio.
Se costruisci con Koder.ai (koder.ai), vale la pena decidere il pattern di ID prima di generare molte tabelle e API. La modalità planning della piattaforma e gli snapshot/rollback rendono più semplice applicare e validare cambiamenti di schema quando il sistema è ancora abbastanza piccolo da poterli modificare senza rischio.
Inizia dal dolore futuro che vuoi evitare: inserimenti lenti dovuti a scritture casuali sull'indice, paginazione scomoda, migrazioni rischiose o collisioni di ID durante importazioni e merge. Se prevedi che i dati si muovano tra sistemi o vengano creati in più posti, scegli per default un ID globalmente unico (UUID/ULID) e separa le preoccupazioni sull'ordinamento temporale.
Un bigint serial è una scelta solida quando hai un solo database, scritture intense e gli ID restano interni. È compatto, efficiente per gli indici B-tree e facile da leggere nei log. Il principale svantaggio è la difficoltà nel fondere dati in seguito senza collisioni, e il fatto che possa rivelare il numero di record se esposto pubblicamente.
Scegli UUID quando i record possono essere creati in più servizi, regioni, dispositivi o client offline e vuoi rischio di collisione estremamente basso senza coordinazione. Gli UUID funzionano bene anche come ID pubblici perché sono difficili da indovinare. Il compromesso tipico sono indici più grandi e pattern di inserimento più casuali rispetto a chiavi sequenziali.
ULID ha senso quando vuoi ID generabili ovunque e che generalmente si ordinano per tempo di creazione. Questo semplifica la paginazione basata su cursori e riduce il dolore degli inserimenti casuali tipici di UUIDv4. Tuttavia, non trattare ULID come un timestamp perfetto: usa created_at quando ti serve un ordinamento rigoroso o per sicurezza nel backfill.
Sì, specialmente con UUIDv4 casuali su tabelle con molti scritti. Gli inserimenti casuali si distribuiscono sull'indice primario, causando più split di pagina, più cache miss e indici più grandi nel tempo. Lo noterai prima come tassi di inserimento sostenuti più lenti e maggior uso di memoria/IO, più che come lookup a singola riga più lenti.
Ordinare per un ID casuale (come UUIDv4) non corrisponde al tempo di creazione, quindi i cursori "after this id" non danno una timeline stabile. La soluzione affidabile è paginare per created_at e aggiungere l'ID come tie-breaker, ad esempio (created_at, id). Se vuoi paginare solo per ID, un ID ordinabile per tempo come ULID è di solito più semplice.
Gli ID sequenziali collidono tra shard perché ogni shard genererà 1, 2, 3... indipendentemente. Puoi evitare collisioni con coordinazione (range per shard o un servizio ID centrale), ma questo aggiunge complessità operativa e può diventare un collo di bottiglia. UUID/ULID riducono la necessità di coordinazione perché ogni shard può generare ID in sicurezza da solo.
UUID/ULID sono più facili: puoi esportare righe, importarle altrove e mantenere le relazioni intatte senza rinumerare. Con ID seriali, le importazioni parziali spesso richiedono una tabella di traduzione (old_id -> new_id) e la riscrittura accurata delle foreign key, facile da sbagliare. Se cloni ambienti o unisci dataset frequentemente, gli ID globalmente unici fanno risparmiare tempo.
Un pattern comune è avere due ID: una chiave primaria interna compatta (serial bigint) per join ed efficienza di storage, più un ID pubblico immutabile (ULID o UUID) per URL, API, esportazioni e riferimenti cross-system. Questo mantiene il database veloce e rende integrazioni e migrazioni meno dolorose. L'importante è trattare l'ID pubblico come stabile e non riciclarlo o reinterpretarlo.
Pianificalo presto e applicalo in modo coerente su tabelle e API. In Koder.ai, decidi la strategia ID predefinita in planning mode prima di generare molte tabelle e endpoint, poi usa snapshot/rollback per convalidare i cambi mentre il progetto è ancora piccolo. La parte più difficile non è creare nuovi ID, ma aggiornare foreign key, cache, log e integrazioni esterne che continuano a riferirsi ai vecchi ID.