Destino Dev

Blog

UUID vs SERIAL vs ULID no PostgreSQL: escolher e usar com Prisma

Por John Pires · 11 de março de 2026

UUID vs SERIAL vs ULID no PostgreSQL: escolher e usar com Prisma

Por que aprender isso em 2026

Em 2026, aplicações distribuídas, microserviços e requisitos de escalabilidade continuam dominando projetos reais. Escolher o tipo de identificador primário certo para suas entidades afeta migrações, performance de consultas, replicação, troubleshooting e UX dos times. Dados consistentes, previsíveis e fáceis de debugar são resultado de decisões simples: usar SERIAL/IDENTITY, UUID ou ULID. Cada opção tem vantagens práticas: IDs autoincrementais são compactos e simples; UUIDs oferecem unicidade sem coordenação; ULIDs combinam unicidade com ordenação temporal legível.

Este artigo mostra o que cada opção realmente faz, como isso impacta Postgres e Prisma, exemplos práticos, armadilhas comuns e um mini-projeto passo a passo. Não haverá floreios: foco em comandos e código que você pode rodar hoje.

O que você precisa saber antes

Você precisa entender apenas o básico: instalar PostgreSQL, Node.js e o Prisma CLI. O conteúdo explica os comandos exatos e a progressão do Hello World para um mini-projeto CRUD que compara as três estratégias de ID.

Resumo rápido das opções

  • SERIAL / IDENTITY: Inteiro autoincremental gerenciado pelo banco. Simples e eficiente para índices B-tree.
  • UUID: 128 bits, único globalmente. Bom para sistemas distribuídos, mas índices maiores e aleatórios.
  • ULID: 26 caracteres, lexicalmente ordenável por timestamp e único. Menos aleatório que UUID e mais compacto.
Dica: pense em IDs como ferramentas operacionais. Escolha pelo comportamento que você precisa: previsibilidade, distribuição ou ordenação temporal.

Configuração do ambiente

Será necessário um ambiente mínimo: PostgreSQL (13+ recomendado), Node.js (16+), npm/yarn e Prisma. Abaixo comandos para Ubuntu/macOS (adaptáveis).

Instalar PostgreSQL e criar banco

Exemplo rápido para macOS com Homebrew; adapte para seu SO.

brew install postgresql
brew services start postgresql
createdb ids_demo

O que isso faz: instala o servidor Postgres, inicia o serviço e cria um banco chamado 'ids_demo'. Depois você pode conectar com psql ou uma GUI.

Node.js e Prisma

Crie um projeto Node, instale Prisma, o cliente e o pacote ULID se quiser gerar ULIDs no app.

mkdir ids-demo && cd ids-demo
npm init -y
npm install prisma @prisma/client ulid
npx prisma init

O comando npx prisma init cria um arquivo .env e uma pasta prisma/schema.prisma. Abra .env e ajuste DATABASE_URL para apontar ao seu 'ids_demo'. Exemplo:

DATABASE_URL='postgresql://postgres:password@localhost:5432/ids_demo?schema=public'

Depois disso você estará pronto para definir modelos e gerar migrações.

Hello World progressivo

Vamos implementar três tabelas em um mesmo banco para comparar comportamento: users_serial, users_uuid, users_ulid. Cada tabela terá schema, exemplo de inserção e leitura usando SQL puro e Prisma.

1) SERIAL / IDENTITY

O SERIAL é sintaticamente simples, mas modernamente recomendamos IDENTITY. Abaixo um CREATE TABLE usando IDENTITY (compatível com SQL padrão).

-- Cria tabela com id autoincremental (INTEGER) usando IDENTITY
CREATE TABLE users_serial (
  id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  created_at TIMESTAMPTZ DEFAULT now()
);

O que acontece: Postgres cria uma sequência interna e atribui valores crescentes. Ótimo para índices compactos e joins rápidos. Desvantagem: coordenação se tiver múltiplas réplicas com escrita ou migrações que reescrevem IDs.

2) UUID

Usamos a função gen_random_uuid() do módulo pgcrypto. Se não tiver, instale com CREATE EXTENSION.

-- Habilita geracao de uuids aleatorios e cria tabela usando type uuid
CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TABLE users_uuid (
  id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
  email text NOT NULL UNIQUE,
  created_at timestamptz DEFAULT now()
);

O que acontece: IDs 128-bit gerados no banco, únicos globalmente. Índices maiores que inteiros; inserts tendem a ficar espalhados (random) no índice, potencialmente causando page splits sob alta escrita.

3) ULID

Postgres não tem ULID nativo padrão amplamente disponível. Prática comum: gerar ULID no app e armazenar como char(26) ou varchar(26). Exemplo abaixo armazena ULID como texto fixo.

CREATE TABLE users_ulid (
  id char(26) PRIMARY KEY,
  email text NOT NULL UNIQUE,
  created_at timestamptz DEFAULT now()
);

O que acontece: ULIDs são ordenáveis por tempo na representação textual, o que favorece inserções próximas no índice. Recomendado gerar no app com pacote 'ulid' para controle e previsibilidade.

Prisma: mapeamento e exemplos

Agora exponho modelos Prisma para cada abordagem. Em prisma/schema.prisma, mude datasource para apontar seu DATABASE_URL.

Modelo para SERIAL / autoincrement

Use Int com autoincrement. Assim o Prisma usa sequence/IDENTITY internamente no Postgres.

// prisma/schema.prisma (trecho)
model UserSerial {
  id         Int      @id @default(autoincrement())
  email      String   @unique
  createdAt  DateTime @default(now()) @map('created_at')
  @@map('users_serial')
}

O que acontece: Prisma gera SQL para criar a tabela com integer autoincrement. As queries do client usam números e funcionam como em qualquer exemplo de ORM.

Modelo para UUID

Prisma tem helper @default(uuid()) para gerar no cliente; também é possível deixar o banco gerar com dbgenerated/ gen_random_uuid(). Ambas opções são válidas. Exemplo com @default(uuid()):

// prisma/schema.prisma (trecho)
model UserUuid {
  id         String   @id @default(uuid()) @db.Uuid
  email      String   @unique
  createdAt  DateTime @default(now()) @map('created_at')
  @@map('users_uuid')
}

O que acontece: Prisma chama UUID no client antes do INSERT. Alternativamente, usar @default(dbgenerated('gen_random_uuid()')) delega ao banco. Escolha depende se você quer IDs antes do roundtrip.

Modelo para ULID

Prisma não tem @default(ulid()). Você gera ULID no app e insere. Definimos campo String com tamanho fixo.

// prisma/schema.prisma (trecho)
model UserUlid {
  id         String   @id @db.Char(26)
  email      String   @unique
  createdAt  DateTime @default(now()) @map('created_at')
  @@map('users_ulid')
}

O que acontece: você precisa gerar id antes de criar o registro (no Node.js), por exemplo usando o pacote 'ulid'. Isso preserva ordenação temporal e gera chaves curtas.

Hello World com Prisma Client (exemplos)

A seguir, um script Node.js que insere um registro em cada tabela e consulta. Gere o client com 'npx prisma generate' e rode o script.

// scripts/demo.js
const { PrismaClient } = require('@prisma/client');
const { ulid } = require('ulid');
const prisma = new PrismaClient();

async function main() {
  // SERIAL: id será autogerado no banco
  const s = await prisma.userSerial.create({ data: { email: 'serial@example.com' } });
  console.log('serial', s);

  // UUID: prisma gera uuid() por @default(uuid()) no client
  const u = await prisma.userUuid.create({ data: { email: 'uuid@example.com' } });
  console.log('uuid', u);

  // ULID: geramos aqui e passamos ao prisma
  const ulidId = ulid();
  const ul = await prisma.userUlid.create({ data: { id: ulidId, email: 'ulid@example.com' } });
  console.log('ulid', ul);
}

main()
  .catch(e => { console.error(e); process.exit(1); })
  .finally(async () => { await prisma.$disconnect(); });

O que acontece: o script cria um registro em cada tabela. Observe os formatos de id nos logs: inteiro, uuid string e 26-char ulid.

Mini-projeto prático

Vamos montar um pequeno CRUD que demonstra paginação por primary key, vantagens de ULID para paginação baseada em cursor, e como lidar com joins e migration. Passos: criar schema Prisma, gerar migração, rodar script de seed e criar endpoints simples com Express (ou usar scripts CLI).

Migração e criação das tabelas

Use Prisma Migrate para criar tabelas conforme os modelos acima. Exemplo de comando:

npx prisma migrate dev --name init_ids_demo

O que acontece: Prisma cria arquivos SQL de migração e aplica ao banco. Verifique no psql que as tabelas users_serial, users_uuid e users_ulid existem e têm as colunas esperadas.

Seed básico

Crie um seed script para popular amostras de 100 registros em cada tabela, útil para testar performance e paginação. Exemplo simplificado:

// prisma/seed.js
const { PrismaClient } = require('@prisma/client');
const { ulid } = require('ulid');
const prisma = new PrismaClient();

async function seed() {
  for (let i = 0; i < 100; i++) {
    await prisma.userSerial.create({ data: { email: `s${i}@example.com` } });
    await prisma.userUuid.create({ data: { email: `u${i}@example.com` } });
    await prisma.userUlid.create({ data: { id: ulid(), email: `x${i}@example.com` } });
  }
}

seed().finally(() => prisma.$disconnect());

O que acontece: popula as três tabelas. No caso do serial, os ids ficam 1..100; UUID serão randômicos; ULIDs ordenados por tempo de inserção.

Paginação por cursor: exemplo prático

Comparação rápida: paginar por id com autoincrement é trivial; com UUID você precisa de offset/limit ou cursor com coluna adicional created_at; ULID permite cursor baseado na string id por ser lexicograficamente ordenável.

// Exemplo de query para paginação baseada em ULID
SELECT * FROM users_ulid WHERE id > '01F8MECHZX3TBDSZ7XRADM79XV' ORDER BY id LIMIT 20;

-- Para UUID a mesma abordagem não funciona bem porque ordenacao por uuid nao representa tempo
-- Para UUID prefira created_at como cursor

O que acontece: ULID permite queries eficientes sem adicionar coluna created_at para cursor. UUID exige coluna extra se você quer ordenacao temporal.

Erros comuns, armadilhas e dicas práticas

Armadilha 1: escolher UUID por moda

Muitos escolhem UUID por acreditar que é sempre a melhor opção. Isso é mito. Se sua aplicação é monolítica e não precisa de IDs globais, um inteiro autoincrement é mais eficiente e simples de debugar.

Armadilha 2: esquecer o tamanho do índice

Index size importa. UUIDs aumentam o tamanho dos índices ~16 bytes por chave; ULIDs ~26 bytes em texto, ou 13 bytes se armazenados como binary (não padrão). Em tabelas muito grandes, o custo de I/O e cache aumenta. Meça antes.

Dicas práticas

  • Se você precisa de unicidade global independentemente do banco: UUID é boa escolha.
  • Se precisa de leitura de inserts ordenados por tempo e compactação: ULID oferece ordering temporal e chaves legíveis.
  • Se quer simplicidade e índices menores: serial/identity é preferível.
  • Gere IDs no cliente quando: você quer o ID antes do insert (ex: criar relacionamentos antes de persistir).
Dica: em sistemas distribuídos com escrita em múltiplas regiões, UUID ou ULID (gerados no app) evitam necessidade de coordenação para sequências.

Recursos para continuar estudando

Documentação e leitura adicional para se aprofundar e justificar escolhas em arquiteturas reais.

Recomendo testes de carga com sua própria carga de trabalho: crie scripts que insiram milhões de registros e monitore índice, page splits e cache hit ratio.

Conclusão prática

Decisão técnica é contextual. Regra prática resumida:

  1. Se seu sistema é simples e centralizado: usE SERIAL/IDENTITY (Int autoincrement).
  2. Se precisa de unicidade global sem coordenação: usE UUID e avalie gerar no cliente ou no banco conforme necessidade.
  3. Se quer unicidade + ordenação temporal eficiente para paginação e um identificador curto: considere ULID, gerando no app e armazenando como char(26).

Com Prisma, o mapeamento é direto: autoincrement com Int, uuid() com String/@db.Uuid ou delegando ao banco, e ULID gerado no cliente salvo como String. Teste cada abordagem no seu cenário, meça latência e uso de disco, e priorize previsibilidade operacional nas decisões.

Agora você tem os passos para experimentar na prática: criar tabelas, gerar migrações com Prisma, popular dados e medir comportamento. Execute os exemplos acima, altere o volume e meça usando pg_stat_user_tables e explain analyze. Isso dará a resposta objetiva para o seu caso.

Assuntos: PostgreSQL, Prisma, uuid, ulid, serial, modelagem

Leia também