UUID vs ULID vs IDs seriais: entenda como cada um afeta indexação, ordenação, sharding e export/import de dados em projetos reais.

Escolher um ID parece chato na primeira semana. Depois você lança, os dados crescem e aquela decisão “simples” aparece em todo lugar: índices, URLs, logs, exports e integrações.
A verdadeira pergunta não é “qual é o melhor?” e sim “qual dor eu quero evitar mais tarde?”. IDs são difíceis de mudar porque eles acabam copiados em outras tabelas, em cache por clientes e dependidos por outros sistemas.
Quando o ID não combina com a evolução do produto, você normalmente percebe em alguns pontos:
Sempre há um trade-off entre conveniência agora e flexibilidade depois. Inteiros seriais são fáceis de ler e costumam ser rápidos, mas podem vazar contagens de registros e dificultar a fusão de datasets. UUIDs aleatórios são ótimos para unicidade entre sistemas, mas prejudicam índices e são menos amigáveis para humanos. ULIDs tentam conciliar unicidade global com ordenação por tempo, mas ainda têm custos de armazenamento e ferramentas.
Uma forma útil de pensar: para quem é o ID principalmente?
Se o ID é mais para humanos (suporte, depuração, ops), algo curto e legível tende a vencer. Se é para máquinas (escritas distribuídas, clientes offline, multi-região), unicidade global e evitar colisões importam mais.
Quando as pessoas debatem “UUID vs ULID vs IDs seriais”, elas estão escolhendo como cada linha recebe um rótulo único. Esse rótulo afeta o quão fácil é inserir, ordenar, mesclar e mover dados depois.
Um ID serial é um contador. O banco distribui 1, depois 2, depois 3, e assim por diante (geralmente armazenado como integer ou bigint). É fácil de ler, barato de armazenar e normalmente rápido porque novas linhas vão para o fim do índice.
Um UUID é um identificador de 128 bits que parece aleatório, como 3f8a.... Na maioria dos cenários pode ser gerado sem pedir ao banco o próximo número, então sistemas diferentes podem criar IDs independentemente. A troca é que inserções com aparência aleatória podem exigir mais trabalho dos índices e ocupar mais espaço que um simples bigint.
Um ULID também tem 128 bits, mas é projetado para ser aproximadamente ordenado por tempo. ULIDs mais novos geralmente ordenam depois dos mais antigos, mantendo unicidade global. Você obtém parte do benefício de “gerar em qualquer lugar” dos UUIDs com um comportamento de ordenação mais amigável.
Resumo simples:
IDs seriais são comuns em apps com um único banco e ferramentas internas. UUIDs aparecem quando dados são criados entre múltiplos serviços, dispositivos ou regiões. ULIDs são populares quando equipes querem geração distribuída de IDs mas ainda se importam com ordenação, paginação ou consultas “mais recentes primeiro”.
Uma chave primária costuma ser apoiada por um índice (geralmente B-tree). Pense nesse índice como uma lista telefônica ordenada: cada nova linha precisa de uma entrada colocada no lugar certo para que buscas continuem rápidas.
Com IDs aleatórios (UUIDv4 clássico), novas entradas caem por toda a árvore do índice. Isso faz com que o banco toque muitas páginas de índice, divida páginas com mais frequência e faça escritas extras. Com o tempo você tem mais churn no índice: mais trabalho por inserção, mais misses de cache e índices maiores do que o esperado.
Com IDs em sua maioria crescentes (serial/bigint, ou IDs ordenados por tempo como muitos ULIDs), o banco geralmente consegue anexar novas entradas perto do fim do índice. Isso é melhor para cache porque as páginas recentes permanecem “quentes” e as inserções tendem a ser mais suaves sob altas taxas de escrita.
O tamanho da chave importa porque entradas de índice não são gratuitas:
Chaves maiores significam menos entradas por página de índice. Isso costuma levar a índices mais profundos, mais páginas lidas por consulta e mais RAM necessária para manter desempenho.
Se você tem uma tabela de “events” com inserções constantes, uma chave primária UUID aleatória pode começar a ficar mais lenta mais cedo que uma chave bigint, mesmo que buscas por linha única ainda pareçam normais. Se você espera escritas pesadas, o custo de indexação costuma ser a primeira diferença real que você nota.
Se você já construiu “Carregar mais” ou scroll infinito, já sentiu a dor de IDs que não ordenam bem. Um ID “ordena bem” quando ordenar por ele dá uma ordem estável e significativa (frequentemente o tempo de criação), então a paginação é previsível.
Com IDs aleatórios (como UUIDv4), linhas mais novas ficam espalhadas. Ordenar por id não corresponde ao tempo, e paginação por cursor como “me dê itens após este id” torna-se pouco confiável. Normalmente você recorre a created_at, o que é ok, mas precisa fazer com cuidado.
ULIDs foram projetados para serem aproximadamente ordenados por tempo. Se você ordenar por ULID (como string ou em forma binária), itens mais novos tendem a vir depois. Isso torna a paginação por cursor mais simples porque o cursor pode ser o último ULID visto.
ULID ajuda com ordenação natural por tempo para feeds, cursores mais simples e menos inserções aleatórias que UUIDv4.
Mas ULID não garante ordem perfeita quando muitos IDs são gerados no mesmo milissegundo em máquinas diferentes. Se você precisa de ordenação exata, ainda quer um timestamp real.
created_at ainda é melhorOrdenar por created_at é frequentemente mais seguro quando você faz backfill, importa registros históricos ou precisa de desempate claro.
Um padrão prático é ordenar por (created_at, id), onde id é apenas o critério de desempate.
Sharding significa dividir um banco em vários menores para que cada shard contenha parte dos dados. Times normalmente fazem isso mais tarde, quando um único banco fica difícil de escalar ou vira ponto único de falha.
Sua escolha de ID pode tornar o sharding gerenciável ou doloroso.
Com IDs sequenciais (auto-increment serial ou bigint), cada shard vai gerar 1, 2, 3.... O mesmo ID pode existir em vários shards. Na primeira vez que você precisar mesclar dados, mover linhas ou construir features cross-shard, aparecem colisões.
Você pode evitar colisões com coordenação (um serviço central de IDs ou ranges por shard), mas isso adiciona partes móveis e pode se tornar um gargalo.
UUIDs e ULIDs reduzem a coordenação porque cada shard pode gerar IDs independentemente com risco extremamente baixo de duplicatas. Se você acha que vai dividir dados entre bancos, esse é um dos argumentos mais fortes contra sequências puras.
Um compromisso comum é adicionar um prefixo de shard e então usar uma sequência local em cada shard. Dá para armazenar como duas colunas ou empacotar num único valor.
Funciona, mas cria um formato de ID customizado. Toda integração precisa entendê-lo, a ordenação deixa de significar ordem global por tempo sem lógica extra, e mover dados entre shards pode exigir reescrever IDs (o que quebra referências se esses IDs forem compartilhados).
Pergunte cedo: você alguma vez precisará combinar dados de vários bancos e manter referências estáveis? Se sim, planeje IDs globalmente únicos desde o início ou reserve orçamento para migrar depois.
Exportar e importar é onde a escolha de ID para de ser teórica. No momento em que você clona prod para staging, restaura um backup ou mescla dados de dois sistemas, você descobre se seus IDs são estáveis e portáveis.
Com IDs seriais (auto-increment), geralmente você não pode reaplicar inserts em outro banco esperando que referências permaneçam intactas, a menos que preserve os números originais. Se você importa apenas um subconjunto de linhas (por exemplo, 200 clientes e seus pedidos), precisa carregar tabelas na ordem certa e manter as mesmas chaves primárias. Se algo for renumerado, chaves estrangeiras quebram.
UUIDs e ULIDs são gerados fora da sequência do banco, então são mais fáceis de mover entre ambientes. Você pode copiar linhas, manter os IDs e os relacionamentos continuam batendo. Isso ajuda ao restaurar backups, fazer exports parciais ou mesclar datasets.
Exemplo: exportar 50 contas da produção para debugar um problema em staging. Com chaves primárias UUID/ULID, você pode importar essas contas mais linhas relacionadas (projetos, faturas, logs) e tudo ainda aponta para o mesmo pai. Com IDs seriais, muitas vezes você acaba criando uma tabela de tradução (old_id -> new_id) e reescrevendo chaves estrangeiras durante a importação.
Para imports em massa, o básico importa mais que o tipo de ID:
Você consegue tomar uma decisão sólida rapidamente se focar no que vai doer depois.
Anote seus maiores riscos futuros. Eventos concretos ajudam: dividir em múltiplos bancos, mesclar dados de outro sistema, escritas offline, cópias frequentes entre ambientes.
Decida se a ordenação por ID precisa casar com o tempo. Se você quer “mais recentes primeiro” sem colunas extras, ULID (ou outro ID ordenável por tempo) é uma opção limpa. Se ordenar por created_at te serve, UUIDs e seriais funcionam.
Estime volume de escrita e sensibilidade do índice. Se você espera inserções pesadas e o índice da chave primária será muito usado, um BIGINT serial costuma ser menos custoso nos B-trees. UUIDs aleatórios tendem a causar mais churn.
Escolha um padrão e documente exceções. Mantenha simples: um padrão para a maioria das tabelas e uma regra clara para quando desviar (frequentemente: IDs públicos vs IDs internos).
Deixe abertura para mudar. Evite codificar significado nos IDs, decida onde IDs são gerados (DB vs app) e mantenha constraints explícitas.
A maior armadilha é escolher um ID porque está na moda e depois descobrir que ele não combina com como você consulta, escala ou compartilha dados. A maioria dos problemas aparece meses depois.
Falhas comuns:
123, 124, 125, pessoas podem adivinhar registros vizinhos e sondar seu sistema.Sinais de alerta que você deve tratar cedo:
Escolha um tipo de chave primária e mantenha-o na maioria das tabelas. Misturar tipos (bigint em um lugar, UUID em outro) complica joins, APIs e migrações.
Estime o tamanho do índice na escala esperada. Chaves mais largas significam índices primários maiores e mais IO e memória.
Decida como vai paginar. Se paginar por ID, garanta que o ID tenha ordenação previsível (ou aceite que não terá). Se paginar por timestamp, indexe created_at e use consistentemente.
Teste seu plano de import em dados parecidos com produção. Verifique se você consegue recriar registros sem quebrar chaves estrangeiras e se reimports não geram IDs novos silenciosamente.
Escreva sua estratégia de colisões. Quem gera o ID (DB ou app) e o que acontece se dois sistemas criarem registros offline e depois sincronizarem?
Garanta que URLs públicas e logs não vazem padrões que você se importa (contagem de registros, ritmo de criação, dicas de shard). Se usar IDs seriais, presuma que as pessoas podem adivinhar IDs próximos.
Um fundador solo lança um CRM simples: contatos, negócios, notas. Um Postgres, um web app, objetivo é entregar rápido.
No início, uma chave primária serial bigint parece perfeita. Inserções são rápidas, índices se mantêm organizados e é fácil ler nos logs.
Um ano depois, um cliente pede exports trimestrais para auditoria e o fundador começa a importar leads de uma ferramenta de marketing. IDs que eram internos agora aparecem em CSVs, e-mails e tickets de suporte. Se dois sistemas usam 1, 2, 3..., fusões viram bagunça. Você acaba adicionando colunas de origem, tabelas de mapeamento ou reescrevendo IDs durante importação.
No segundo ano, surge um app móvel que precisa criar registros offline e sincronizar depois. Agora você precisa de IDs que possam ser gerados no cliente sem falar com o banco e quer baixo risco de colisão quando os dados chegarem de ambientes diferentes.
Um compromisso que costuma envelhecer bem:
Se você está em dúvida entre UUID, ULID e seriais, decida com base em como seus dados vão se mover e crescer.
Escolhas rápidas para casos comuns:
bigint serial como chave primária.Misturar frequentemente é a melhor resposta. Use bigint serial para tabelas internas que nunca saem do seu banco (tabelas de join, jobs em background) e UUID/ULID para entidades públicas como users, orgs, invoices e qualquer coisa que você possa exportar, sincronizar ou referenciar de outro serviço.
Se você está construindo no Koder.ai (koder.ai), vale decidir seu padrão de IDs antes de gerar muitas tabelas e APIs. O modo de planejamento da plataforma e snapshots/rollback facilitam aplicar e validar mudanças de esquema cedo, enquanto o sistema ainda é pequeno o bastante para mudar com segurança.
Comece com a dor futura que você quer evitar: inserções lentas por causa de índices aleatórios, paginação estranha, migrações arriscadas ou colisões de IDs em importações e fusões. Se você espera que os dados se movam entre sistemas ou sejam criados em vários lugares, prefira um ID globalmente único (UUID/ULID) e mantenha as preocupações sobre ordenação por tempo separadas.
Serial bigint é uma ótima escolha quando você tem um único banco de dados, volumes de escrita elevados e os IDs permanecem internos. É compacto, rápido para índices B-tree e fácil de ler nos logs. A principal desvantagem é que é difícil mesclar dados depois sem colisões, e expor esses IDs pode revelar quantos registros você tem.
Escolha UUIDs quando registros podem ser criados em múltiplos serviços, regiões, dispositivos ou clientes offline e você quer risco extremamente baixo de colisão sem coordenação. UUIDs também funcionam bem como IDs públicos porque são difíceis de adivinhar. A contrapartida comum é índices maiores e inserções mais aleatórias em comparação com chaves sequenciais.
ULIDs fazem sentido quando você quer IDs que possam ser gerados em qualquer lugar e que, em geral, ordenem por tempo de criação. Isso simplifica paginação por cursor e reduz a dor de inserções aleatórias que você vê com UUIDv4. Ainda assim, não trate ULID como um timestamp perfeito; use created_at quando precisar de ordenação estrita ou segurança em backfills.
Sim, especialmente com UUIDv4 em tabelas com muitas escritas. Inserções aleatórias espalham as entradas pelo índice da chave primária, causando mais splits de página, churn de cache e índices maiores ao longo do tempo. Normalmente você percebe primeiro taxas de inserção sustentadas mais lentas e maior uso de memória/IO, não necessariamente consultas de linha única lentas.
Ordenar por um ID aleatório (como UUIDv4) não vai corresponder à ordem de criação, então cursores do tipo “após este id” não produzem uma linha do tempo estável. A solução confiável é paginar por created_at e adicionar o ID como critério de desempate, por exemplo (created_at, id). Se você quer paginar apenas por ID, prefira um ID ordenável por tempo como ULID.
IDs sequenciais colidem entre shards porque cada shard vai gerar 1, 2, 3... independentemente. Você pode evitar colisões com coordenação (faixas por shard ou um serviço central de IDs), mas isso adiciona complexidade operacional e pode se tornar um gargalo. UUIDs/ULIDs reduzem a necessidade de coordenação porque cada shard pode gerar IDs de forma segura por conta própria.
UUIDs/ULIDs são mais fáceis porque você pode exportar linhas, importar em outro lugar e manter referências intactas sem renumerar. Com IDs seriais, importações parciais costumam exigir uma tabela de tradução (old_id -> new_id) e reescrita cuidadosa das chaves estrangeiras, o que é fácil de errar. Se você frequentemente clona ambientes ou mescla conjuntos de dados, IDs globalmente únicos economizam tempo.
Um padrão comum é ter dois IDs: uma chave primária interna compacta (serial bigint) para joins e eficiência de armazenamento, e um ID público imutável (ULID ou UUID) para URLs, APIs, exportações e referências entre sistemas. Isso mantém o banco rápido e facilita integrações e migrações. O importante é tratar o ID público como estável e nunca reciclá-lo ou reinterpretá-lo.
Planeje isso cedo e aplique de forma consistente em tabelas e APIs. No Koder.ai, decida sua estratégia padrão de IDs no modo de planejamento antes de gerar muitas tabelas e endpoints, e use snapshots/rollback para validar mudanças enquanto o projeto ainda é pequeno. A parte mais difícil não é criar novos IDs — é atualizar chaves estrangeiras, caches, logs e integrações externas que continuam referenciando os antigos.