O que é Drizzle?
Drizzle é um TypeScript ORM que trata o SQL como um cidadão de primeira classe. Seu schema é TypeScript comum, suas queries são construídas a partir de métodos que espelham as cláusulas SQL, e o runtime é leve o suficiente para rodar no edge. Não há etapa de geração de código nem engine binária: você importa drizzle-orm e faz a query.
Esse design reflete-se em cada API. db.select().from(users).where(eq(users.id, id)) é lido na mesma ordem que o SQL que ele produz. A biblioteca não esconde o banco de dados, ela o tipa. Se você já entende de joins, indexes e ON CONFLICT, você já entende a maior parte do Drizzle.
Drizzle suporta PostgreSQL, MySQL e SQLite, além de drivers serverless e edge como Neon, PlanetScale, Turso, Cloudflare D1 e o SQLite nativo do Bun. O mesmo estilo de schema e query funciona em todos eles, e é por isso que ele se tornou uma escolha comum para bancos de dados edge e serverless.
Seu schema é TypeScript
Não existe schema.prisma nem cliente gerado. Você define as tabelas com um builder por dialeto e as exporta.
import { pgTable, text, uuid, boolean, timestamp, integer } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull().unique(),
name: text("name"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const posts = pgTable("posts", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
title: text("title").notNull(),
published: boolean("published").notNull().default(false),
authorId: uuid("author_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
O primeiro argumento é o nome da tabela no SQL, e cada builder de coluna recebe o nome da coluna. Esse mapeamento explícito permite que o banco de dados utilize snake_case enquanto seu código utiliza camelCase. Métodos de coluna como .notNull(), .unique() e .default() adicionam as mesmas constraints que você escreveria manualmente.
As relações são declaradas separadamente com relations(). Elas não criam colunas; elas informam à API de queries relacionais como as tabelas se conectam.
import { relations } from "drizzle-orm";
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}));
Tipagem gratuita
O Drizzle infere os tipos das linhas a partir do schema, então raramente você precisará escrevê-los manualmente. Cada tabela expõe $inferSelect e $inferInsert:
import { users } from "./schema";
type User = typeof users.$inferSelect;
type NewUser = typeof users.$inferInsert;
function displayName(user: User) {
return user.name ?? user.email;
}
Os helpers InferSelectModel e InferInsertModel fazem a mesma coisa e podem ser mais legíveis em algumas bases de código:
import type { InferInsertModel, InferSelectModel } from "drizzle-orm";
type User = InferSelectModel<typeof users>;
type NewUser = InferInsertModel<typeof users>;
Como esses tipos vêm do mesmo objeto que constrói a query, a renomeação de uma coluna atualiza todos os consumidores simultaneamente. Esse é o benefício de manter o schema em TypeScript em vez de uma DSL separada: não existe um arquivo gerado que possa ficar dessincronizado.
Constraints e índices ficam ao lado das colunas
As regras ao nível de coluna abrangem NOT NULL, UNIQUE e valores padrão. Regras ao nível de tabela, como chaves compostas, índices e checks, ficam no terceiro argumento opcional, que retorna um array.
import { sql } from "drizzle-orm";
import {
check,
index,
pgTable,
primaryKey,
text,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
export const memberships = pgTable(
"memberships",
{
userId: uuid("user_id").notNull().references(() => users.id),
orgId: uuid("org_id").notNull(),
role: text("role").notNull().default("member"),
},
(table) => [
primaryKey({ columns: [table.userId, table.orgId] }),
index("memberships_org_idx").on(table.orgId),
uniqueIndex("memberships_user_org_idx").on(table.userId, table.orgId),
check("memberships_role_check", sql`${table.role} in ('member', 'admin')`),
],
);
Manter os índices no schema, em vez de em um script executado manualmente, significa que o drizzle-kit generate os cria junto com a tabela, e cada ambiente os recebe na mesma ordem. Se uma query estiver lenta, o índice de que ela precisa geralmente deve estar aqui, ao lado da coluna que ele cobre.
Conectando a um banco de dados
Crie uma conexão de driver, envolva-a com drizzle e passe o schema para que a API relacional consiga identificar suas relações.
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool, { schema });
Substitua node-postgres pelo driver que você realmente for implantar: drizzle-orm/neon-http para o endpoint HTTP do Neon, drizzle-orm/postgres-js, drizzle-orm/better-sqlite3, drizzle-orm/d1 ou drizzle-orm/bun-sqlite. A API de query acima de db é idêntica, que é exatamente o objetivo.
Drivers serverless e de edge
O motivo pelo qual o Drizzle funciona tão bem em runtimes de edge é que não há nada para empacotar além do driver. Neon over HTTP e Cloudflare D1 são os dois que você encontrará com mais frequência.
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });
import { drizzle } from "drizzle-orm/d1";
export default {
async fetch(_request: Request, env: Env) {
const db = drizzle(env.DB);
const rows = await db.select().from(users);
return Response.json(rows);
},
};
Crie o client uma vez por isolate, em vez de por requisição, e passe { schema } para que o db.query continue funcionando. Drivers HTTP e D1 podem limitar prepared statements ou transações dependendo do protocolo, portanto, verifique as notas do driver antes de depender de qualquer um deles em produção.
O query builder parece SQL
As queries são montadas através do encadeamento de métodos que mapeiam para cláusulas SQL. select, from, where, orderBy, limit e offset alinham-se perfeitamente com a instrução que geram.
import { eq, desc, ilike, and } from "drizzle-orm";
const rows = await db
.select({
id: posts.id,
title: posts.title,
publishedAt: posts.createdAt,
})
.from(posts)
.where(and(eq(posts.published, true), ilike(posts.title, "%drizzle%")))
.orderBy(desc(posts.createdAt))
.limit(20);
Os operadores são funções importadas em vez de símbolos: eq, ne, gt, gte, lt, lte, inArray, notInArray, like, ilike, isNull, isNotNull, e os combinadores and, or e not. Quando você precisa de algo que o builder não expõe, sql permite que você utilize SQL puro mantendo a parametrização.
import { sql } from "drizzle-orm";
const active = await db
.select()
.from(users)
.where(sql`${users.createdAt} > now() - interval '30 days'`);
Filtragem, ordenação e paginação
As condições são compostas com and, or e not, e as funções de operador cobrem os predicados usuais. Lê-las em conjunto é muito próximo de ler uma cláusula WHERE.
import { and, asc, gt, inArray, isNull, or } from "drizzle-orm";
const page = await db
.select({ id: posts.id, title: posts.title, createdAt: posts.createdAt })
.from(posts)
.where(and(eq(posts.published, true), gt(posts.id, cursor)))
.orderBy(asc(posts.id))
.limit(20);
Isso é a paginação por keyset: em vez de offset, que faz com que o banco de dados escaneie e descarte linhas, você pagina a partir do último id visualizado. Para conjuntos de resultados menores, limit e offset funcionam perfeitamente, e para filtros anuláveis ou baseados em conjuntos, o mesmo builder se aplica:
const results = await db
.select()
.from(users)
.where(
or(
isNull(users.name),
inArray(users.email, ["[email protected]", "[email protected]"]),
),
);
A ordenação utiliza asc e desc em colunas, ou sql para expressões como desc(sqlcount(${posts.id})). Como cada cláusula é explícita, não há ordenação oculta ou filtro padrão para te surpreender.
Joins e agregados
Os joins são explícitos, exatamente como no SQL. Você escolhe entre innerJoin, leftJoin ou rightJoin e fornece a condição.
const report = await db
.select({
id: users.id,
email: users.email,
postCount: sql<number>`count(${posts.id})`.mapWith(Number),
})
.from(users)
.leftJoin(posts, eq(posts.authorId, users.id))
.where(eq(posts.published, true))
.groupBy(users.id)
.orderBy(desc(sql`count(${posts.id})`))
.limit(20);
Como você mesmo escreve o groupBy, não existe um plano de consulta oculto. Se o SQL estivesse errado, o TypeScript também estaria. Subqueries, CTEs com with, window functions e operações de conjunto estão todas disponíveis, seja através de helpers dedicados ou através de fragmentos sql.
A API de consultas relacionais
O query builder é preciso, porém verboso para o caso comum de carregar um usuário com seus posts. A API de consultas relacionais, db.query, assemelha-se mais ao Prisma:
const result = await db.query.users.findMany({
columns: { id: true, email: true },
with: {
posts: {
columns: { id: true, title: true },
where: (posts, { eq }) => eq(posts.published, true),
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
limit: 10,
},
},
});
db.query.users.findMany e findFirst compreendem o relations() que você declarou, e with os carrega em uma única viagem (round trip) por relação. As duas APIs compartilham o mesmo schema e podem ser misturadas em um único codebase: use o query builder quando precisar de controle, e db.query quando quiser um resultado aninhado sem precisar escrever o join.
Gravando dados
Inserts, updates e deletes são igualmente diretos. Adicione .returning() para recuperar as linhas afetadas em uma única instrução, evitando a necessidade de um SELECT posterior.
await db
.insert(users)
.values({ email: "[email protected]", name: "Ada" })
.returning();
await db
.insert(posts)
.values([
{ title: "First", authorId },
{ title: "Second", authorId, published: true },
])
.onConflictDoNothing({ target: posts.id });
await db
.update(users)
.set({ name: "Ada Lovelace" })
.where(eq(users.id, id))
.returning();
await db.delete(posts).where(eq(posts.id, postId));
onConflictDoNothing e onConflictDoUpdate mapeiam para ON CONFLICT para upserts. returning() é suportado no PostgreSQL, SQLite e MariaDB; o MySQL não oferece suporte, então você deve realizar um novo select. A API reflete deliberadamente o dialeto ao qual você está conectado, em vez de fingir que todos os bancos de dados são idênticos.
Transações
Transações são um callback que recebe um handle de transação. Toda query interna deve utilizar esse handle.
await db.transaction(async (tx) => {
await tx
.update(accounts)
.set({ balance: sql`${accounts.balance} - 5000` })
.where(eq(accounts.id, from));
await tx
.update(accounts)
.set({ balance: sql`${accounts.balance} + 5000` })
.where(eq(accounts.id, to));
});
Lançar um erro (throw) dentro do callback reverte a transação (rollback). Chamadas aninhadas de tx.transaction tornam-se savepoints onde o dialeto as suporta, e você pode definir um nível de isolamento através do objeto de opções. Como em qualquer banco de dados, mantenha o callback curto e não utilize await para chamadas de rede não relacionadas dentro dele.
Migrations com drizzle-kit
drizzle-kit é a CLI complementar. Ela lê drizzle.config.ts e o schema, e gerencia as migrations.
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: { url: process.env.DATABASE_URL! },
});
pnpm drizzle-kit generate
pnpm drizzle-kit migrate
pnpm drizzle-kit push
pnpm drizzle-kit studio
generate compara o schema com o último snapshot, escreve um arquivo SQL numerado em out e registra um snapshot no journal. migrate aplica os arquivos pendentes em ordem. Ambos são seguros para produção porque o SQL é um artefato revisável que você commita.
push é o atalho para prototipagem: ele faz com que o banco de dados corresponda ao schema sem escrever arquivos de migration. É conveniente em desenvolvimento e arriscado em produção pelos mesmos motivos de qualquer comando de schema-push. pull faz o caminho inverso, fazendo a introspecção de um banco de dados existente para TypeScript, e studio abre um navegador de dados local. Para drivers apenas HTTP, como o Neon, use o migrador do driver em vez da CLI:
import { migrate } from "drizzle-orm/neon-http/migrator";
await migrate(db, { migrationsFolder: "./drizzle" });
Prepared statements
O Drizzle pode preparar uma query uma única vez e executá-la diversas vezes com parâmetros diferentes, o que economiza o overhead de planejamento em caminhos críticos (hot paths).
const getUser = db
.select()
.from(users)
.where(eq(users.id, sql.placeholder("id")))
.prepare("get_user");
const user = await getUser.execute({ id: userId });
Os placeholders são declarados com sql.placeholder("name") e fornecidos no momento da execução. Os prepared statements são nomeados para que o driver possa fazer o cache do plano; lembre-se que poolers de conexão em modo de transação, como o PgBouncer, podem não preservá-los entre diferentes conexões.
Um ORM, múltiplos bancos de dados
Como o query builder reflete o SQL, o Drizzle se adapta a cada dialeto em vez de simplificá-los. Você importa os construtores de colunas de drizzle-orm/pg-core, drizzle-orm/mysql-core ou drizzle-orm/sqlite-core, e os tipos e funções disponíveis seguem o banco de dados.
Isso significa que recursos específicos do PostgreSQL, como jsonb, arrays, pgEnum e colunas geradas, são tratados como cidadãos de primeira classe, enquanto MySQL e SQLite possuem seus próprios equivalentes. O custo disso é que mover um schema entre dialetos exige trabalho real: as abstrações são compartilhadas, mas o SQL não. Essa é uma escolha deliberada para equipes que preferem as capacidades reais do banco de dados em vez de um denominador comum mínimo.
Por que parece SQL
A maioria dos ORMs esconde o banco de dados e expõe objetos. O Drizzle faz o oposto: ele fornece tipos em torno do banco de dados que você já conhece. Disso decorrem três consequências.
Primeiro, o debugging é mais fácil. A query que você escreveu é a query que é executada, portanto, ler um log de lentidão ou uma saída de EXPLAIN mapeia diretamente de volta para o código. Segundo, o aprendizado é transferível, pois o conhecimento de joins, índices e ON CONFLICT aplica-se sem alterações. Terceiro, há menos “mágica” para te surpreender: sem proxies de lazy-loading que disparam queries quando você acessa uma propriedade, e sem identity map decidindo o que já está na memória.
O custo é que espera-se que você conheça SQL. O Drizzle gerará alegremente uma query ineficiente se você solicitar uma, e não irá te salvar de um índice ausente.
Performance e tamanho do bundle
O Drizzle não utiliza engine em Rust nem cliente gerado. O runtime é TypeScript puro com suporte a tree-shaking, portanto, uma serverless function ou edge worker carrega apenas o query builder e o driver. Não há um binário separado para fazer o bundle ou causar cold-start, o que é um dos principais motivos pelos quais as plataformas de edge o preferem.
No servidor, a história é a mesma de qualquer ORM: o banco de dados faz o trabalho pesado, e a forma como você estrutura suas queries define a performance. Use .returning() para evitar round trips extras, prepare queries frequentes, selecione apenas as colunas necessárias e deixe os índices fazerem o trabalho deles. Como nada é cacheado ou processado em lote (batched) sem o seu conhecimento, a performance que você mede é a performance do SQL que você escreveu.
Melhores práticas
- Mantenha o
schema.tscomo a única fonte de verdade e exporte tabelas e relações de um único lugar. - Use
drizzle-kit generatee faça o commit do SQL; nunca deixe opushtocar em produção. - Selecione apenas as colunas necessárias e adicione
.returning()em vez de realizar uma leitura subsequente. - Prefira
db.querycomwithpara leituras aninhadas e o query builder para qualquer coisa personalizada. - Envolva escritas de múltiplas etapas em
db.transactione utilize o handle dotxem todo o processo. - Use placeholders
sqle prepared statements em caminhos críticos (hot paths). - Escolha o driver adequado para a plataforma e compartilhe uma única instância de
dbpor processo. - Adicione índices no schema ao lado das colunas que eles cobrem.
- Execute as migrations no CI/CD antes do deploy do novo código.
Erros comuns
- Executar
drizzle-kit pushcontra um banco de dados de produção e remover colunas. - Usar o
dbexterno dentro de uma transação em vez dotxfornecido. - Esquecer de passar
{ schema }paradrizzle, o que quebra odb.query. - Escrever condições
wherecom o operador errado, comoeqondeinArrayseria necessário. - Selecionar todas as colunas por padrão em caminhos críticos (hot paths).
- Omitir índices em chaves estrangeiras usadas em joins.
- Assumir que o MySQL suporta
.returning(). - Tratar fragmentos
sqlcomo automaticamente seguros quando interpolam strings de usuários. - Esquecer de executar
generateapós editar o schema e depois questionar por que a migration está vazia.
Próximos passos
O Drizzle valoriza o conhecimento do banco de dados subjacente, por isso, leia o guia de PostgreSQL para aprender sobre índices, transações e planejamento de consultas. Compare-o com o Prisma, que gera um cliente a partir de um schema, e com o TypeORM, que modela entidades como classes. Se você ainda está aprimorando seus conhecimentos na linguagem, o guia de TypeScript é a base na qual todos os três se apoiam.