UUID vs ULID vs seriële IDs: leer hoe elk type indexering, sortering, sharding en veilige export/import beïnvloedt in echte projecten.

Een ID-keuze voelt in week één saai. Daarna gooi je iets live, groeit de data, en die "simpele" beslissing duikt overal op: indexen, URLs, logs, exports en integraties.
De echte vraag is niet "wat is het beste?" maar "welke pijn wil ik later vermijden?" ID's zijn moeilijk te veranderen omdat ze gekopieerd worden naar andere tabellen, gecachet door clients en afhankelijk zijn voor andere systemen.
Als het ID niet past bij hoe het product evolueert, zie je dat meestal op een paar plekken:
Er is altijd een afweging tussen gemak nu en flexibiliteit later. Seriële integers zijn makkelijk leesbaar en vaak snel, maar ze kunnen het aantal records lekken en het samenvoegen van datasets moeilijker maken. Willekeurige UUID's zijn geweldig voor uniekheid tussen systemen, maar ze zijn zwaarder voor indexen en moeilijker voor mensen in logs. ULID's proberen wereldwijde uniekheid met tijd-achtige ordening te combineren, maar ze hebben ook opslag- en tooling-tradeoffs.
Een nuttige manier om erover na te denken: voor wie is het ID vooral bedoeld?
Als het ID vooral voor mensen is (support, debugging, ops), winnen kortere en makkelijker te scannen ID's vaak. Als het voor machines is (gedistribueerde writes, offline clients, multi-region systemen), zijn globale uniekheid en botsingspreventie belangrijker.
Als mensen discussiëren over "UUID vs ULID vs seriële IDs" kiezen ze eigenlijk hoe elke rij een unieke label krijgt. Die label beïnvloedt hoe makkelijk het later is om te inserten, sorteren, samenvoegen en verplaatsen.
Een seriële ID is een teller. De database geeft 1, dan 2, dan 3, enzovoort (vaak opgeslagen als integer of bigint). Het is makkelijk te lezen, goedkoop om op te slaan en meestal snel omdat nieuwe rijen aan het einde van de index komen te staan.
Een UUID is een 128-bit identifier die willekeurig lijkt, zoals 3f8a.... In de meeste setups kun je die genereren zonder de database te vragen om het volgende nummer, dus verschillende systemen kunnen onafhankelijk ID's maken. Het nadeel is dat willekeurige inserts indexen harder laten werken en meer ruimte innemen dan een simpele bigint.
Een ULID is ook 128-bit, maar is ontworpen om ruwweg tijd-ordened te zijn. Nieuwere ULID's sorteren meestal na oudere, terwijl ze toch globaal uniek blijven. Je krijgt vaak een deel van het "overal genereren" voordeel van UUID's met vriendelijker sorteer-gedrag.
Een eenvoudige samenvatting:
Seriële ID's zijn gebruikelijk voor single-database apps en interne tools. UUID's verschijnen wanneer data over meerdere services, apparaten of regio's wordt aangemaakt. ULID's zijn populair wanneer teams gedistribueerde ID-generatie willen maar nog steeds geven om sorteerorde, paginatie of "nieuwste eerst" queries.
Een primary key wordt meestal ondersteund door een index (vaak een B-tree). Zie die index als een gesorteerd telefoonboek: elke nieuwe rij heeft een vermelding die op de juiste plek gezet moet worden zodat lookups snel blijven.
Bij willekeurige ID's (klassieke UUIDv4) landen nieuwe entries overal in de index. Dat betekent dat de database veel indexpagina's moet aanraken, pagina's vaker splitst en extra schrijfacties doet. Na verloop van tijd krijg je meer index-churn: meer werk per insert, meer cache-misses en grotere indexen dan je verwacht.
Bij grotendeels toenemende ID's (serieel/bigint, of tijd-geordende ID's zoals veel ULID-implementaties) kan de database meestal nieuwe entries append-en dicht bij het einde van de index. Dat is beter voor de cache omdat recente pagina's hot blijven, en inserts verlopen meestal vloeiender bij hogere schrijf-snelheden.
Sleutelgrootte doet ertoe omdat indexvermeldingen niet gratis zijn:
Grotere keys betekenen dat minder vermeldingen op een indexpagina passen. Dat leidt vaak tot diepere indexen, meer pagina's die per query gelezen moeten worden en meer RAM om snel te blijven.
Als je een "events"-tabel hebt met constante inserts, kan een random UUID primary key eerder traag aanvoelen dan een bigint key, zelfs als single-row lookups nog prima zijn. Als je veel writes verwacht, is indexeringskost meestal het eerste echte verschil dat je merkt.
Als je "Meer laden" of infinite scroll hebt gebouwd, ken je de pijn van ID's die niet goed sorteren. Een ID "sorteert goed" wanneer ordenen op dat ID je een stabiele, betekenisvolle volgorde geeft (vaak creatietijd) zodat paginatie voorspelbaar is.
Met willekeurige ID's (zoals UUIDv4) zijn nieuwere rijen verspreid. Ordenen op id komt dan niet overeen met tijd, en cursor-paginatie zoals "geef me items na deze id" wordt onbetrouwbaar. Je valt meestal terug op created_at, wat prima is, maar je moet het zorgvuldig doen.
ULID's zijn ontworpen om ruwweg op tijd te ordenen. Als je op ULID sorteert (als string of in binaire vorm), komen nieuwere items meestal later. Dat maakt cursor-paginatie eenvoudiger omdat de cursor de laatst geziene ULID kan zijn.
ULID helpt met natuurlijke tijd-achtige ordening voor feeds, eenvoudigere cursors en minder willekeurige insert-gedrag dan UUIDv4.
Maar ULID garandeert geen perfecte tijdsvolgorde wanneer veel ID's in dezelfde milliseconde op meerdere machines worden gegenereerd. Als je exacte ordering nodig hebt, wil je nog steeds een echte timestamp.
created_at nog steeds beter isSorteren op created_at is vaak veiliger wanneer je data backfillt, historische records importeert of duidelijke tie-breaking nodig hebt.
Een praktisch patroon is ordenen op (created_at, id), waarbij id alleen als tie-breaker dient.
Sharding betekent het opsplitsen van één database in meerdere kleinere zodat elke shard een deel van de data bevat. Teams doen dit meestal later, wanneer één database moeilijk te schalen is of te riskant wordt als single point of failure.
Je ID-keuze kan sharding beheersbaar of pijnlijk maken.
Bij sequentiële ID's (auto-increment serial of bigint) zal elke shard vrolijk 1, 2, 3... genereren. Dezelfde ID kan op meerdere shards bestaan. De eerste keer dat je data moet samenvoegen, rijen verplaatsen of cross-shard features bouwen, krijg je collisies.
Je kunt collisies vermijden met coördinatie (een centrale ID-service of ranges per shard), maar dat voegt bewegende delen toe en kan een bottleneck worden.
UUIDs en ULIDs verminderen coördinatie omdat elke shard onafhankelijk IDs kan genereren met extreem lage kans op duplicaten. Als je ooit data over databases heen wilt verdelen, is dit één van de sterkste argumenten tegen pure sequenties.
Een veelvoorkomend compromis is het toevoegen van een shard-prefix en vervolgens een lokale sequence op elke shard gebruiken. Je kunt het opslaan als twee kolommen of samenpakken in één waarde.
Het werkt, maar het creëert een aangepast ID-formaat. Elke integration moet het begrijpen, sorteren stopt met globale tijdsvolgorde zonder extra logica, en het verplaatsen van data tussen shards kan vereisen dat je ID's herschrijft (wat referenties breekt als die ID's gedeeld worden).
Stel vroeg één vraag: moet je ooit data uit meerdere databases combineren en referenties stabiel houden? Zo ja, plan dan voor globale unieke ID's vanaf dag één, of budgetteer een migratie later.
Export en import is waar de ID-keuze ophoudt theoretisch te zijn. Zodra je prod kloont naar staging, een backup herstelt of data uit twee systemen samenvoegt, ontdek je of je ID's stabiel en draagbaar zijn.
Met seriële (auto-increment) ID's kun je meestal niet veilig inserts opnieuw afspelen in een andere database en verwachten dat referenties intact blijven tenzij je de originele nummers behoudt. Als je slechts een subset van rijen importeert (bijv. 200 klanten en hun orders), moet je tabellen in de juiste volgorde laden en exact dezelfde primary keys behouden. Als er iets hernummerd wordt, breken foreign keys.
UUIDs en ULIDs worden buiten de database-sequence gegenereerd, dus ze zijn makkelijker naar andere omgevingen te verplaatsen. Je kunt rijen kopiëren, ID's behouden en relaties blijven kloppen. Dit helpt bij backups herstellen, deel-exports of datasets samenvoegen.
Voorbeeld: exporteer 50 accounts uit productie om een issue in staging te debuggen. Met UUID/ULID-primary keys kun je die accounts en gerelateerde rijen (projects, invoices, logs) importeren en alles verwijst nog steeds naar de juiste ouder. Met seriële ID's bouw je vaak een mapping-tabel (old_id -> new_id) en schrijf je foreign keys tijdens import over.
Voor bulk-imports zijn de basisprincipes belangrijker dan het ID-type:
Je kunt snel een solide beslissing nemen als je focust op wat later zal pijn doen.
Schrijf je grootste toekomstige risico's op. Concrete gebeurtenissen helpen: opsplitsen in meerdere databases, data van klanten samenvoegen, offline writes, frequente kopieën van omgevingen.
Bepaal of ID-sortering tijdsvolgorde moet volgen. Als je "nieuwste eerst" wilt zonder extra kolommen, is ULID (of een andere tijd-sorterende ID) passend. Als je prima vindt te sorteren op created_at, werken UUIDs en seriële ID's beide.
Schat schrijfvolume en index-gevoeligheid. Als je veel inserts verwacht en je primary key-index zwaar belast wordt, is een seriële BIGINT meestal het vriendelijkst voor B-tree-indexen. Willekeurige UUID's veroorzaken meestal meer churn.
Kies een standaard en documenteer uitzonderingen. Houd het simpel: één standaard voor de meeste tabellen en een duidelijke regel voor afwijkingen (vaak: publieke ID's vs interne ID's).
Laat ruimte om te veranderen. Vermijd het coderen van betekenis in ID's, beslis waar ID's worden gegenereerd (DB vs app) en houd constraints expliciet.
De grootste val is een ID kiezen omdat het populair is en later ontdekken dat het botst met hoe je queryt, schaalt of data deelt. De meeste problemen verschijnen maanden later.
Veelvoorkomende fouten:
123, 124, 125 gebruiken, kunnen mensen nabije records raden en je systeem scannen.Waarschuwingssignalen die je vroeg moet aanpakken:
Kies één primary key-type en houd je er in de meeste tabellen aan. Mixen (bigint op de ene plek, UUID op de andere) maakt joins, API's en migraties lastiger.
Schat indexgrootte op je verwachte schaal. Brede keys betekenen grotere primaire indexen en meer geheugen en IO.
Bepaal hoe je gaat pagineren. Als je pagineert op ID, zorg dat het ID voorspelbare ordering heeft (of accepteer dat het dat niet doet). Als je pagineert op timestamp, indexeer created_at en gebruik het consequent.
Test je importplan met productie-achtige data. Controleer dat je records kunt recreëren zonder foreign keys te breken en dat re-imports niet stilletjes nieuwe ID's genereren.
Schrijf je collisiestrategie op. Wie genereert het ID (DB of app) en wat gebeurt er als twee systemen offline records maken en later syncen?
Zorg dat publieke URLs en logs geen patronen lekken die je belangrijk vindt (recordaantallen, creatiesnelheid, interne shard-hints). Als je seriële ID's gebruikt, ga ervan uit dat mensen nabije ID's kunnen raden.
Een solo-founder lanceert een eenvoudige CRM: contacts, deals, notes. Eén Postgres-database, één webapp en het doel is speed to market.
In het begin voelt een seriële bigint primary key perfect. Inserts zijn snel, indexen blijven netjes en het is makkelijk te lezen in logs.
Een jaar later vraagt een klant om kwartaal-exports voor een audit en begint de founder leads te importeren uit een marketingtool. ID's die eerst intern waren verschijnen nu in CSV's, e-mails en supporttickets. Als twee systemen beide 1, 2, 3... gebruiken, wordt samenvoegen rommelig. Je voegt bron-kolommen, mapping-tabellen toe of herschrijft ID's tijdens import.
In jaar twee is er een mobiele app die offline records moet maken en later syncen. Nu heb je ID's nodig die client-side gegenereerd kunnen worden zonder de database te raken, en je wilt laag collisierisico wanneer data in verschillende omgevingen landen.
Een compromis dat vaak goed werkt:
Als je twijfelt tussen UUID, ULID en seriële ID's, beslis op basis van hoe je data zal bewegen en groeien.
Korte aanbevelingen voor veelvoorkomende gevallen:
bigint seriële primary key.Mixen is vaak het beste antwoord. Gebruik seriële bigint voor interne tabellen die nooit je database verlaten (join-tables, background jobs) en gebruik UUID/ULID voor publieke entiteiten zoals users, orgs, invoices en alles wat je mogelijk exporteert, syncet of referentieert vanuit een andere service.
Als je bouwt in Koder.ai (Koder.ai), is het de moeite waard je ID-patroon te bepalen voordat je veel tabellen en API's genereert. De planningsmodus van het platform en snapshots/rollback maken het makkelijker om schema-wijzigingen vroeg toe te passen en te valideren, terwijl het systeem nog klein genoeg is om veilig te wijzigen.
Begin met de toekomstige pijn die je wilt vermijden: langzame inserts door willekeurige index-schrijftoegang, lastige paginatie, risicovolle migraties of ID-collisies bij imports en merges. Als je verwacht dat data tussen systemen beweegt of op meerdere plekken wordt aangemaakt, kies dan standaard voor een globaal uniek ID (UUID/ULID) en behandel time-ordering apart.
Serieel bigint is een sterke default wanneer je één database hebt, veel schrijft en ID's intern blijven. Het is compact, snel voor B-tree-indexen en makkelijk te lezen in logs. Het nadeel is dat samenvoegen van data later lastig is vanwege collisies en dat recordaantallen kunnen uitlekken als je ze publiek maakt.
Kies UUID's als records in meerdere services, regio's, apparaten of offline clients kunnen worden aangemaakt en je zeer lage collisierisico's wilt zonder coördinatie. UUID's werken ook goed als publieke ID's omdat ze moeilijk te raden zijn. De gebruikelijke trade-off zijn grotere indexen en meer willekeurige insert-patronen vergeleken met sequentiële sleutels.
ULID's zijn handig als je ID's wilt die overal gegenereerd kunnen worden én doorgaans op creatietijd sorteren. Dit vereenvoudigt cursor-paginatie en vermindert de “willekeurige insert”-pijn die je bij UUIDv4 ziet. Behandel ULID echter niet als een perfecte timestamp; gebruik created_at als je strikte ordering of backfill-veiligheid nodig hebt.
Ja — vooral op write-heavy tabellen met UUIDv4-stijl willekeurigheid. Willekeurige inserts verspreiden zich over de primaire sleutel-index, wat meer page-splits, cache-churn en grotere indexen veroorzaakt. Meestal merk je dit eerst als langzamere aanhoudende insert-snelheden en hogere geheugen/IO-behoeften, niet als trage enkele-rij zoekopdrachten.
Ordenen op een willekeurige ID (zoals UUIDv4) zal niet overeenkomen met creatietijd, dus cursors als “na deze id” geven geen stabiele tijdlijn. De betrouwbare oplossing is pagineren op created_at en de ID als tie-breaker toevoegen, bijvoorbeeld (created_at, id). Als je per ID wilt pagineren, is een tijd-sorteerbare ID zoals ULID meestal eenvoudiger.
Seriële ID's botsen over shards omdat elke shard onafhankelijk 1, 2, 3... genereert. Je kunt collisies vermijden met coördinatie (shard-ranges of een centrale ID-service), maar dat voegt operationele complexiteit toe en kan een bottleneck worden. UUIDs/ULIDs verminderen de noodzaak van coördinatie omdat elke shard veilig IDs kan genereren.
UUIDs/ULIDs zijn veiliger omdat je rijen kunt exporteren en importeren en relaties intact blijven zonder hernummeren. Met seriële ID's vereisen gedeeltelijke imports vaak een vertaal-tabel (old_id -> new_id) en zorgvuldige herschrijving van foreign keys, wat makkelijk fout gaat. Als je vaak omgevingen kloont of datasets samenvoegt, besparen globale ID's veel tijd.
Een veelgebruikt patroon is twee ID's: een compact interne primaire sleutel (seriële bigint) voor joins en opslag‑efficiëntie, plus een onveranderlijke publieke ID (ULID of UUID) voor URLs, API's, exports en cross-system referenties. Dit houdt de database snel terwijl integraties en migraties minder pijnlijk worden. Behandel de publieke ID als stabiel en recycleer of interpreteer deze nooit opnieuw.
Plan het vroeg en pas het consequent toe over tabellen en API's. In Koder.ai, bepaal je standaard ID-strategie in de planningsmodus voordat je veel schema's en endpoints genereert, en gebruik snapshots/rollback om wijzigingen te valideren terwijl het project nog klein genoeg is om veilig aan te passen. Het lastigste is niet nieuwe ID's maken — het zijn foreign keys, caches, logs en externe payloads die lang naar de oude verwijzen.