Drizzle ORM com PostgreSQL combina uma API tipada para TypeScript com consultas próximas do SQL. A biblioteca permite definir tabelas no código, criar migrations e executar operações usando o driver do PostgreSQL. Ela é útil para equipes que desejam ajuda do compilador sem esconder completamente joins, índices, transações e decisões do banco.
Tipagem não substitui validação em runtime, modelagem ou conhecimento de SQL. Uma consulta pode estar correta para o TypeScript e ainda ser lenta, bloquear linhas ou retornar dados que a aplicação não deveria expor. Neste guia, você verá como organizar schema, conexão, migrations, queries e testes. Para fundamentos, consulte PostgreSQL e Node.js.
Instalação
npm init -y
npm install drizzle-orm pg
npm install --save-dev drizzle-kit @types/pg typescriptUse uma versão suportada do Node.js e mantenha o lockfile versionado. A URL de conexão deve vir do ambiente ou de um gerenciador de segredos.
Definindo o schema
import {
pgTable,
uuid,
varchar,
integer,
timestamp,
index,
uniqueIndex
} from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
email: varchar('email', { length: 320 }).notNull(),
name: varchar('name', { length: 120 }).notNull(),
createdAt: timestamp('created_at', {
withTimezone: true
}).defaultNow().notNull()
}, table => [
uniqueIndex('users_email_unique').on(table.email)
]);
export const orders = pgTable('orders', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'restrict' }),
totalCents: integer('total_cents').notNull(),
status: varchar('status', { length: 30 }).notNull(),
createdAt: timestamp('created_at', {
withTimezone: true
}).defaultNow().notNull()
}, table => [
index('orders_user_created_idx').on(
table.userId,
table.createdAt
)
]);Defina restrições no banco, não apenas no código. NOT NULL, unique, foreign keys e checks impedem estados inválidos mesmo quando outro serviço ou script escreve diretamente.
Conexão e pool
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import * as schema from './schema.js';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
connectionTimeoutMillis: 3000,
idleTimeoutMillis: 30000,
statement_timeout: 5000,
application_name: 'orders-api'
});
export const db = drizzle(pool, { schema });O pool existe por processo. Se usar vários workers, multiplique o limite pela quantidade de instâncias. O artigo sobre pool PostgreSQL no Node.js explica como evitar excesso de conexões.
Inserindo dados
const [user] = await db
.insert(users)
.values({
email: input.email.toLowerCase(),
name: input.name
})
.returning({
id: users.id,
email: users.email,
name: users.name
});Retorne somente os campos necessários. Trate violações de unique como conflito de negócio, sem expor detalhes internos da constraint ao cliente.
Consultas com filtros
import { and, desc, eq, lt } from 'drizzle-orm';
const page = await db
.select({
id: orders.id,
totalCents: orders.totalCents,
status: orders.status,
createdAt: orders.createdAt
})
.from(orders)
.where(and(
eq(orders.userId, userId),
cursor ? lt(orders.createdAt, cursor) : undefined
))
.orderBy(desc(orders.createdAt))
.limit(50);Prefira paginação por cursor para tabelas grandes. OFFSET precisa percorrer e descartar linhas à medida que a página cresce. A ordenação deve ser estável; adicione id como desempate quando timestamps podem se repetir.
Joins
const result = await db
.select({
orderId: orders.id,
totalCents: orders.totalCents,
customerName: users.name,
customerEmail: users.email
})
.from(orders)
.innerJoin(users, eq(orders.userId, users.id))
.where(eq(orders.id, orderId));Selecione campos explicitamente. Isso evita transferir colunas sensíveis e torna o contrato da consulta visível. Use EXPLAIN ANALYZE em consultas importantes e crie índices baseados no padrão real de filtros e ordenação.
Transações
await db.transaction(async tx => {
const [order] = await tx
.insert(orders)
.values({
userId,
totalCents,
status: 'pending'
})
.returning({ id: orders.id });
await tx.insert(outboxEvents).values({
aggregateId: order.id,
type: 'order.created',
payload: { orderId: order.id, totalCents }
});
});Transações devem ser curtas. Não faça chamadas HTTP ou espere filas enquanto mantém locks. O padrão outbox grava o evento na mesma transação e publica depois, evitando inconsistência entre banco e mensageria.
Migrations
Use drizzle-kit para gerar migrations revisáveis. Nunca use push automático no banco de produção sem entender o SQL.
npx drizzle-kit generate
npx drizzle-kit migrateLeia cada migration, teste com uma cópia representativa e planeje rollback. Alterações destrutivas devem ser divididas em etapas compatíveis: adicionar, preencher, mudar leitores, mudar escritores e remover somente depois.
Configuração do Drizzle Kit
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema.ts',
out: './drizzle',
dbCredentials: {
url: process.env.DATABASE_URL
},
strict: true,
verbose: true
});Não execute essa configuração em ambientes sem a variável obrigatória. O pipeline de migration deve ter credenciais separadas das credenciais da aplicação, com permissões apropriadas.
Validação de entrada
Os tipos do Drizzle representam o código compilado, mas dados HTTP continuam não confiáveis. Valide comprimento, formato, faixas numéricas e campos permitidos. Veja Zod no TypeScript.
Não passe objetos inteiros recebidos do cliente diretamente para values() ou set(). Mapeie os campos permitidos para impedir mass assignment.
Atualizações seguras
const [updated] = await db
.update(orders)
.set({ status: 'paid' })
.where(and(
eq(orders.id, orderId),
eq(orders.status, 'pending')
))
.returning({ id: orders.id });
if (!updated) {
throw new ConflictError('Order cannot be paid');
}Incluir o estado esperado no WHERE evita sobrescrever mudanças concorrentes. Para modelos mais complexos, use versão ou timestamp de atualização como controle otimista.
SQL customizado
O Drizzle permite fragmentos SQL para recursos específicos. Use parâmetros, nunca concatenação de entrada.
import { sql } from 'drizzle-orm';
const stats = await db.execute(sql`
select status, count(*)::int as total
from orders
where created_at >= ${startDate}
group by status
`);SQL explícito não é uma falha do ORM. Em relatórios e consultas avançadas, ele pode ser a opção mais clara, desde que seja tipado, testado e medido.
Tratamento de erros
Mapeie códigos do PostgreSQL para erros de aplicação. Violação de unique pode virar 409; foreign key inválida pode virar 400 ou 409; timeout pode virar 503. Não envie query, stack ou detalhes da conexão ao usuário.
Use códigos internos e requestId. Registre duração, operação e contexto mínimo, removendo valores sensíveis.
Segurança
- use usuário de banco com privilégios mínimos;
- ative TLS quando a rede não é confiável;
- parametrize SQL customizado;
- não registre senhas nem URLs completas com credenciais;
- aplique limites e timeouts;
- valide campos antes das queries;
- mantenha migrations sob revisão;
- faça backup e teste restauração.
Observabilidade
Meça tempo de aquisição de conexão, duração de consultas, timeouts, erros por código e uso do pool. Slow queries devem ser investigadas com plano de execução. Não use texto completo da query com valores como label de métrica.
O guia sobre performance de APIs Node.js ajuda a conectar métricas do banco à latência HTTP.
Testes
Use um banco isolado para integração, aplique migrations e limpe dados entre testes. Teste constraints, transações, concorrência e paginação. Não substitua todos os testes por mocks, pois boa parte do comportamento está no PostgreSQL.
import test from 'node:test';
import assert from 'node:assert/strict';
test('não cria e-mail duplicado', async () => {
await createUser({ email: 'a@example.com', name: 'A' });
await assert.rejects(
() => createUser({ email: 'a@example.com', name: 'B' }),
error => error.code === 'USER_EMAIL_CONFLICT'
);
});Consulte também testes unitários com Jest.
Erros comuns
- Confiar apenas nos tipos: entrada externa ainda precisa de validação;
- Ignorar índices: queries tipadas continuam podendo ser lentas;
- Migrations automáticas: mudanças destrutivas chegam sem revisão;
- Transações longas: locks e filas aumentam;
- Pool por requisição: conexões são abertas sem controle;
- Select de todas as colunas: dados sensíveis podem vazar.
Checklist
- schema possui constraints reais;
- pool e timeouts foram definidos;
- queries selecionam campos explícitos;
- entradas são validadas;
- migrations são revisadas;
- transações são curtas;
- consultas críticas possuem índices;
- integração usa banco real de teste.
Referências oficiais
Conclusão
Drizzle ORM com PostgreSQL oferece uma camada tipada sem afastar o desenvolvedor do SQL. Schemas, queries e migrations ficam próximos do código, facilitando revisão e refatoração.
O resultado confiável depende de constraints no banco, validação em runtime, pools dimensionados, migrations graduais e consultas medidas. Quando esses elementos são tratados juntos, o ORM melhora produtividade sem esconder as responsabilidades de um banco relacional em produção.




