Regue ORM com PostgreSQL: guia completo
Drizzle ORM ocupa uma posição única no ecossistema de banco de dados TypeScript: é o ORM que na verdade é apenas um construtor de consultas disfarçado, com esquema como código que gera SQL que você pode ler e um sistema de migração que trata seu banco de dados como a fonte da verdade. Depois de anos de Prisma e TypeORM, Drizzle tem vontade de voltar para o SQL bruto - mas com inferência TypeScript completa e zero sobrecarga de tempo de execução.
Este guia cobre tudo, desde a configuração inicial até os padrões de produção, com base em uma base de código com mais de 65 arquivos de esquema Drizzle, consultas multilocatários e um padrão de conexão de proxy lento que funciona de maneira confiável em contextos Next.js e NestJS.
Principais conclusões
- Use um proxy lento para conexão de banco de dados - nunca crie conexões antecipadas no tempo de carregamento do módulo
- O PostgreSQL local deve ser executado em uma porta não padrão (5433) para evitar conflitos com instalações do sistema
- Todas as tabelas precisam de
organizationIdpara multilocação — aplicar no nível do esquema- As comparações de enum de Drizzle precisam de conversão explícita de TypeScript para restrição de tipo
- Nunca use
sql.raw()— sempre use o modelo literalsqlcom valores parametrizados- As relações são separadas das chaves estrangeiras — defina ambas para segurança completa do tipo
- As transações envolvem operações de várias etapas; passe o objeto
txpara serviços- O índice do esquema (
schema/index.ts) deve exportar todas as tabelas para que as migrações funcionem
Instalação e configuração
Drizzle requer dois pacotes: o ORM principal e o driver de banco de dados.
pnpm add drizzle-orm pg
pnpm add -D drizzle-kit @types/pg
Para a pilha completa, incluindo migrações e estúdio:
pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit
O pacote postgres (diferente de pg) é o driver recomendado para Drizzle em projetos TypeScript modernos - ele suporta pooling de conexões, instruções preparadas e possui melhores tipos de TypeScript.
O padrão de proxy preguiçoso
A decisão arquitetônica mais crítica com Drizzle em um monorepo Next.js ou NestJS é nunca criar uma conexão de banco de dados ansiosa. Conexões ansiosas no tempo de carregamento do módulo causam problemas em:
- Next.js: os módulos são importados durante o processo de construção, onde nenhum banco de dados está disponível
- NestJS: as importações de pacotes acontecem antes do carregamento das variáveis de ambiente
- Sem servidor: inicializações a frio são atrasadas pela sobrecarga de conexão
O padrão proxy preguiçoso resolve tudo isso:
// packages/db/src/index.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
let _db: ReturnType<typeof drizzle> | null = null;
function getDb() {
if (!_db) {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL environment variable is not set');
}
const client = postgres(connectionString, {
max: 10, // connection pool size
idle_timeout: 20,
connect_timeout: 10,
});
_db = drizzle(client, { schema });
}
return _db;
}
// Export a Proxy that initializes the connection on first use
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
get(_, prop) {
return getDb()[prop as keyof ReturnType<typeof drizzle>];
},
});
export * from './schema';
Este padrão significa que a importação de @ecosire/db nunca abre uma conexão com o banco de dados. A conexão é criada somente quando a primeira consulta é executada.
Projeto de esquema
Esquemas Drizzle são arquivos TypeScript simples que exportam definições de tabela. A disciplina aqui é manter as tabelas relacionadas no mesmo arquivo e exportar tudo através de um índice central.
// packages/db/src/schema/contacts.ts
import {
pgTable,
uuid,
text,
varchar,
timestamp,
pgEnum,
boolean,
index,
} from 'drizzle-orm/pg-core';
export const contactTypeEnum = pgEnum('contact_type', ['individual', 'company', 'partner']);
export const contacts = pgTable(
'contacts',
{
id: uuid('id').defaultRandom().primaryKey(),
organizationId: uuid('organization_id').notNull(), // Multi-tenancy
name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }),
phone: varchar('phone', { length: 50 }),
type: contactTypeEnum('type').default('individual').notNull(),
isActive: boolean('is_active').default(true).notNull(),
notes: text('notes'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => ({
organizationIdx: index('contacts_organization_idx').on(table.organizationId),
emailIdx: index('contacts_email_idx').on(table.email),
})
);
export type Contact = typeof contacts.$inferSelect;
export type NewContact = typeof contacts.$inferInsert;
Os tipos $inferSelect e $inferInsert fornecem inferência TypeScript completa - Drizzle sabe quais campos são obrigatórios ou opcionais no momento da inserção com base nas restrições do seu esquema.
// packages/db/src/schema/index.ts
export * from './contacts';
export * from './orders';
export * from './licenses';
export * from './products';
// ... all tables
O arquivo de índice deve exportar todas as tabelas para drizzle-kit para pegá-las durante a geração da migração.
Relações
O Drizzle separa as restrições de chave estrangeira (aplicadas pelo PostgreSQL) das definições de relação (usadas pela API de consulta relacional do Drizzle). Você precisa de ambos:
// packages/db/src/schema/relations.ts
import { relations } from 'drizzle-orm';
import { contacts } from './contacts';
import { orders } from './orders';
import { orderItems } from './order-items';
import { products } from './products';
export const contactsRelations = relations(contacts, ({ many }) => ({
orders: many(orders),
}));
export const ordersRelations = relations(orders, ({ one, many }) => ({
contact: one(contacts, {
fields: [orders.contactId],
references: [contacts.id],
}),
items: many(orderItems),
}));
export const orderItemsRelations = relations(orderItems, ({ one }) => ({
order: one(orders, {
fields: [orderItems.orderId],
references: [orders.id],
}),
product: one(products, {
fields: [orderItems.productId],
references: [products.id],
}),
}));
Com as relações definidas, você pode usar a API de consulta relacional do Drizzle:
// Fetch order with contact and items in one query
const order = await db.query.orders.findFirst({
where: eq(orders.id, orderId),
with: {
contact: true,
items: {
with: {
product: true,
},
},
},
});
Drizzle gera uma única consulta JOIN – sem problemas N+1.
Consultas de tipo seguro
A API de consulta do Drizzle é totalmente segura. O tipo de seleção é restringido com base nas colunas que você inclui:
import { db } from '@ecosire/db';
import { contacts } from '@ecosire/db/schema';
import { eq, and, like, sql, count } from 'drizzle-orm';
// Full select — returns Contact[]
const allContacts = await db
.select()
.from(contacts)
.where(eq(contacts.organizationId, orgId))
.limit(50);
// Partial select — returns only specified columns
const contactNames = await db
.select({
id: contacts.id,
name: contacts.name,
email: contacts.email,
})
.from(contacts)
.where(
and(
eq(contacts.organizationId, orgId),
eq(contacts.isActive, true)
)
);
// Count query
const [{ total }] = await db
.select({ total: count() })
.from(contacts)
.where(eq(contacts.organizationId, orgId));
// Search with ILIKE (case-insensitive)
const searchResults = await db
.select()
.from(contacts)
.where(
and(
eq(contacts.organizationId, orgId),
like(contacts.name, `%${searchTerm}%`)
)
)
.limit(20);
Para cláusulas where dinâmicas (filtros da UI), crie condições condicionalmente:
import { SQL } from 'drizzle-orm';
async function searchContacts(orgId: string, filters: {
search?: string;
type?: 'individual' | 'company';
isActive?: boolean;
}) {
const conditions: SQL[] = [eq(contacts.organizationId, orgId)];
if (filters.search) {
conditions.push(like(contacts.name, `%${filters.search}%`));
}
if (filters.type) {
// Enum comparisons need explicit casting
conditions.push(eq(contacts.type, filters.type as 'individual' | 'company' | 'partner'));
}
if (filters.isActive !== undefined) {
conditions.push(eq(contacts.isActive, filters.isActive));
}
return db
.select()
.from(contacts)
.where(and(...conditions))
.limit(100);
}
Observe a conversão explícita nos valores enum: filters.type as 'individual' | 'company' | 'partner'. O tipo enum do Drizzle não aceita automaticamente subtipos de string mais estreitos sem a conversão.
Inserir, atualizar e excluir
import { db } from '@ecosire/db';
import { contacts, NewContact } from '@ecosire/db/schema';
import { eq, and } from 'drizzle-orm';
// Insert with RETURNING
async function createContact(data: NewContact) {
const [created] = await db
.insert(contacts)
.values(data)
.returning();
return created; // Fully typed as Contact
}
// Upsert (insert or update on conflict)
async function upsertContact(data: NewContact) {
const [result] = await db
.insert(contacts)
.values(data)
.onConflictDoUpdate({
target: [contacts.organizationId, contacts.email],
set: {
name: data.name,
updatedAt: new Date(),
},
})
.returning();
return result;
}
// Update with RETURNING
async function updateContact(
id: string,
orgId: string,
updates: Partial<NewContact>
) {
const [updated] = await db
.update(contacts)
.set({ ...updates, updatedAt: new Date() })
.where(
and(
eq(contacts.id, id),
eq(contacts.organizationId, orgId) // Always filter by org
)
)
.returning();
return updated;
}
// Soft delete (recommended over hard delete)
async function deleteContact(id: string, orgId: string) {
return db
.update(contacts)
.set({ isActive: false, updatedAt: new Date() })
.where(
and(
eq(contacts.id, id),
eq(contacts.organizationId, orgId)
)
);
}
Sempre use .returning() em instruções INSERT e UPDATE — isso evita uma segunda viagem de ida e volta SELECT para obter o registro criado/atualizado.
Transações
Operações de várias etapas que devem ser bem-sucedidas ou falhar juntas precisam de transações:
async function createOrderWithItems(
orderData: NewOrder,
items: NewOrderItem[]
) {
return db.transaction(async (tx) => {
// Create the order
const [order] = await tx
.insert(orders)
.values(orderData)
.returning();
// Create order items
const createdItems = await tx
.insert(orderItems)
.values(items.map((item) => ({ ...item, orderId: order.id })))
.returning();
// Update inventory (must succeed or entire transaction rolls back)
for (const item of items) {
await tx
.update(products)
.set({
stock: sql`${products.stock} - ${item.quantity}`,
})
.where(eq(products.id, item.productId));
}
return { order, items: createdItems };
});
}
Ao passar o contexto da transação para métodos de serviço:
// Service accepts optional transaction for composability
async function activateLicense(
licenseId: string,
tx?: typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0]
) {
const queryRunner = tx || db;
return queryRunner
.update(licenses)
.set({ status: 'active', activatedAt: new Date() })
.where(eq(licenses.id, licenseId))
.returning();
}
Migrações com kit drizzle
A CLI drizzle-kit gera migrações a partir de alterações de esquema:
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/schema/index.ts',
out: './src/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});
Fluxo de trabalho:
# Generate migration SQL from schema changes
npx drizzle-kit generate
# Apply migrations to database
npx drizzle-kit migrate
# Push schema directly (dev only — skips migration files)
npx drizzle-kit push
# Open Drizzle Studio (visual database browser)
npx drizzle-kit studio
Para produção, use sempre migrate (não push). O comando push é para iteração de desenvolvimento local — ele não cria arquivos de migração, portanto as alterações não são rastreadas.
Padrão de paginação
A paginação eficiente requer uma consulta de dados e uma consulta de contagem:
interface PaginationParams {
page: number;
pageSize: number;
}
async function getContactsPaginated(orgId: string, { page, pageSize }: PaginationParams) {
const offset = (page - 1) * pageSize;
const [data, [{ total }]] = await Promise.all([
db
.select()
.from(contacts)
.where(eq(contacts.organizationId, orgId))
.limit(pageSize)
.offset(offset)
.orderBy(contacts.createdAt),
db
.select({ total: count() })
.from(contacts)
.where(eq(contacts.organizationId, orgId)),
]);
return {
data,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
A execução de ambas as consultas em paralelo com Promise.all reduz pela metade o tempo de resposta em comparação com a execução sequencial.
Armadilhas e soluções comuns
Armadilha 1: usar sql.raw() para consultas dinâmicas
// Dangerous — SQL injection vulnerability
const results = await db.execute(
sql.raw(`SELECT * FROM contacts WHERE name = '${userInput}'`)
);
// Safe — parameterized
const results = await db.execute(
sql`SELECT * FROM contacts WHERE name = ${userInput}`
);
Armada 2: índices ausentes em colunas consultadas com frequência
Sempre adicione índices às colunas pelas quais você filtra. Os índices perdidos mais comuns:
(table) => ({
organizationIdx: index('contacts_org_idx').on(table.organizationId),
emailIdx: index('contacts_email_idx').on(table.email),
compositeIdx: index('contacts_org_active_idx').on(
table.organizationId,
table.isActive
),
})
Armadilha 3: consultas N+1 do carregamento manual de relações
// N+1 problem — one query per order
const orders = await db.select().from(ordersTable);
for (const order of orders) {
order.contact = await db
.select()
.from(contacts)
.where(eq(contacts.id, order.contactId));
}
// Solution — use Drizzle's relational query API
const orders = await db.query.orders.findMany({
with: { contact: true },
});
Armadilha 4: Esquecer de exportar novas tabelas de schema/index.ts
Se você criar um novo arquivo de esquema, mas esquecer de exportar novamente de schema/index.ts, drizzle-kit não verá a nova tabela e as migrações não serão geradas.
Perguntas frequentes
Como o Drizzle se compara ao Prisma para projetos TypeScript?
Drizzle está mais próximo do SQL bruto com tipos TypeScript – você escreve consultas que mapeiam 1:1 para SQL. O Prisma abstrai mais, com uma linguagem de consulta personalizada e um arquivo de esquema separado. Drizzle tem melhor inferência TypeScript em consultas complexas, menor sobrecarga de tempo de execução e não requer uma etapa de geração de código. O Prisma tem um ecossistema maior e mais recursos integrados, como exclusões suaves e registros de auditoria. Para equipes grandes que estão confortáveis com SQL, o Drizzle geralmente é o preferido.
Devo usar drizzle-kit push ou migrar em CI/CD?
Sempre use drizzle-kit migrate em CI/CD. O comando push é para desenvolvimento local rápido — ele aplica alterações de esquema diretamente, sem criar arquivos de migração, portanto, não há trilha de auditoria e a reversão é difícil. No pipeline de implantação, execute drizzle-kit migrate antes de iniciar o aplicativo para garantir que o esquema esteja sincronizado.
Como lidar com a propagação do banco de dados com o Drizzle?
Crie um arquivo inicial separado e importe seu esquema diretamente. Use onConflictDoNothing() para sementes idempotentes que não falharão na nova execução. Armazene dados iniciais em constantes para atualizações fáceis e execute sementes após as migrações em seu script de configuração de desenvolvimento.
Como faço pesquisa de texto completo com Drizzle e PostgreSQL?
Use o modelo literal sql com as funções to_tsvector e to_tsquery do PostgreSQL. O Drizzle não abstrai a pesquisa de texto completo, mas você pode escrevê-la diretamente. Adicione um índice GIN na coluna tsvector para desempenho e use websearch_to_tsquery para termos de pesquisa fornecidos pelo usuário para analisar com segurança consultas em linguagem natural.
Como lidar com alterações de esquema sem tempo de inatividade?
Use migrações aditivas – adicione colunas como anuláveis primeiro, preencha os dados e, em seguida, adicione a restrição NOT NULL. Nunca descarte colunas na mesma migração que as remove do código. A sequência é: 1) Implantar código que ignore a coluna antiga, 2) Implantar migração para eliminá-la. Isso garante que a reversão seja sempre possível sem perda de dados.
Próximas etapas
Uma camada de banco de dados bem projetada é a base sobre a qual todo aplicativo de produção é construído. A equipe de engenharia da ECOSIRE trabalha diariamente com Drizzle ORM e PostgreSQL, gerenciando mais de 65 arquivos de esquema em um sistema complexo multilocatário.
Se você precisa de consultoria de arquitetura de banco de dados, integração Odoo ERP ou um sistema back-end completo construído com ferramentas TypeScript modernas, explore nossos serviços para saber como podemos ajudar.
Escrito por
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
Artigos Relacionados
Zero-Downtime Database Migrations with Drizzle ORM
Run database migrations without downtime using Drizzle ORM. Covers expand-contract pattern, backward-compatible schema changes, rollback strategies, and CI/CD integration for PostgreSQL.
Odoo Performance Tuning: PostgreSQL and Server Optimization
Expert guide to Odoo 19 performance tuning. Covers PostgreSQL configuration, indexing, query optimization, Nginx caching, and server sizing for enterprise deployments.
Natural Language Database Queries with OpenClaw
How OpenClaw enables natural language database queries, translating plain English business questions into accurate SQL without exposing database credentials or query complexity.