¿Qué es Drizzle?
Drizzle es un ORM de TypeScript que trata a SQL como un ciudadano de primera clase. Tu esquema es TypeScript convencional, tus consultas se construyen a partir de métodos que reflejan las cláusulas de SQL y el runtime es lo suficientemente ligero como para ejecutarse en el edge. No hay pasos de generación de código ni motores binarios: simplemente importas drizzle-orm y realizas la consulta.
Ese diseño se refleja en cada API. db.select().from(users).where(eq(users.id, id)) se lee en el mismo orden que el SQL que produce. La librería no oculta la base de datos, sino que le asigna tipos. Si ya entiendes los joins, los índices y ON CONFLICT, ya entiendes la mayor parte de Drizzle.
Drizzle es compatible con PostgreSQL, MySQL y SQLite, además de drivers serverless y de edge como Neon, PlanetScale, Turso, Cloudflare D1 y el SQLite integrado de Bun. El mismo esquema y estilo de consulta se mantienen en todos ellos, razón por la cual se ha convertido en una opción común para bases de datos edge y serverless.
Tu esquema es TypeScript
No hay schema.prisma ni cliente generado. Defines las tablas con un builder por dialecto y las exportas.
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(),
});
El primer argumento es el nombre de la tabla en SQL, y cada builder de columna recibe el nombre de la columna. Este mapeo explícito permite que la base de datos use snake_case mientras que tu código utiliza camelCase. Los métodos de columna como .notNull(), .unique() y .default() añaden las mismas restricciones que escribirías a mano.
Las relaciones se declaran por separado con relations(). Estas no crean columnas; le indican a la API de consultas relacionales cómo se conectan las tablas.
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],
}),
}));
Los tipos vienen incluidos
Drizzle infiere los tipos de fila a partir del esquema, por lo que rara vez tendrás que escribir uno a mano. Cada tabla expone $inferSelect y $inferInsert:
import { users } from "./schema";
type User = typeof users.$inferSelect;
type NewUser = typeof users.$inferInsert;
function displayName(user: User) {
return user.name ?? user.email;
}
Los helpers InferSelectModel y InferInsertModel hacen lo mismo y resultan más legibles en algunas bases de código:
import type { InferInsertModel, InferSelectModel } from "drizzle-orm";
type User = InferSelectModel<typeof users>;
type NewUser = InferInsertModel<typeof users>;
Debido a que estos tipos provienen del mismo objeto que construye la consulta, si renombras una columna, todos los consumidores se actualizan al instante. Esa es la ventaja de mantener el esquema en TypeScript en lugar de un DSL independiente: no hay archivos generados que puedan desincronizarse.
Las restricciones y los índices conviven con las columnas
Las reglas a nivel de columna cubren NOT NULL, UNIQUE y los valores por defecto. Las reglas a nivel de tabla, como las claves compuestas, los índices y los checks, van en el tercer argumento opcional, el cual devuelve un 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')`),
],
);
Mantener los índices en el esquema en lugar de en un script ejecutado manualmente significa que drizzle-kit generate los crea junto con la tabla, y cada entorno los recibe en el mismo orden. Si una consulta es lenta, el índice que necesita generalmente debe definirse aquí, junto a la columna que cubre.
Conexión a una base de datos
Crea una conexión con el driver, envuélvela con drizzle y pasa el esquema para que la API relacional pueda reconocer tus relaciones.
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 });
Sustituye node-postgres por el driver que vayas a desplegar: drizzle-orm/neon-http para el endpoint HTTP de Neon, drizzle-orm/postgres-js, drizzle-orm/better-sqlite3, drizzle-orm/d1 o drizzle-orm/bun-sqlite. La API de consultas anterior db es idéntica, que es precisamente el objetivo.
Drivers para serverless y edge
La razón por la cual Drizzle se adapta tan bien a los entornos de edge es que no hay nada que empaquetar más allá del driver. Neon over HTTP y Cloudflare D1 son los dos que encontrarás con más frecuencia.
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);
},
};
Crea el cliente una vez por isolate en lugar de hacerlo por cada solicitud, y pasa { schema } para que db.query siga funcionando. Los drivers de HTTP y D1 pueden limitar las prepared statements o las transacciones dependiendo del protocolo, así que revisa las notas del driver antes de depender de cualquiera de las dos en producción.
El query builder se lee como SQL
Las consultas se ensamblan encadenando métodos que se mapean a cláusulas de SQL. select, from, where, orderBy, limit y offset se alinean uno a uno con la sentencia que generan.
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);
Los operadores son funciones importadas en lugar de símbolos: eq, ne, gt, gte, lt, lte, inArray, notInArray, like, ilike, isNull, isNotNull, y los combinadores and, or y not. Cuando necesites algo que el builder no exponga, sql te permite escribir SQL puro manteniendo la parametrización.
import { sql } from "drizzle-orm";
const active = await db
.select()
.from(users)
.where(sql`${users.createdAt} > now() - interval '30 days'`);
Filtrado, ordenación y paginación
Las condiciones se componen con and, or y not, y las funciones de operador cubren los predicados habituales. Leerlas en conjunto es muy similar a leer una 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);
Esto es paginación basada en claves (keyset pagination): en lugar de usar offset, que obliga a la base de datos a escanear y descartar filas, paginas a partir del último id que viste. Para conjuntos de resultados más pequeños, limit y offset funcionan perfectamente, y para filtros basados en conjuntos o valores nulables se aplica el mismo builder:
const results = await db
.select()
.from(users)
.where(
or(
isNull(users.name),
inArray(users.email, ["[email protected]", "[email protected]"]),
),
);
La ordenación utiliza asc y desc en las columnas, o sql para expresiones como desc(sqlcount(${posts.id})). Debido a que cada cláusula es explícita, no hay ordenaciones ocultas ni filtros predeterminados que puedan sorprenderte.
Joins y agregaciones
Los joins son explícitos, exactamente igual que en SQL. Eliges entre innerJoin, leftJoin o rightJoin y proporcionas la condición.
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);
Dado que escribes el groupBy tú mismo, no hay un plan de consulta oculto. Si el SQL fuera incorrecto, el TypeScript también lo sería. Las subconsultas, los CTEs con with, las funciones de ventana y las operaciones de conjunto están disponibles, ya sea a través de helpers dedicados o mediante fragmentos de sql.
La API de consultas relacionales
El constructor de consultas es preciso, pero resulta demasiado verboso para el caso común de cargar un usuario con sus publicaciones. La API de consultas relacionales, db.query, se lee de forma más similar a 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 y findFirst entienden el relations() que declaraste, y with los carga en un solo viaje de ida y vuelta por relación. Ambas APIs comparten el mismo esquema y pueden mezclarse en un mismo proyecto: utiliza el constructor de consultas cuando necesites control, y db.query cuando quieras un resultado anidado sin tener que escribir el join.
Escritura de datos
Las inserciones, actualizaciones y eliminaciones son igual de directas. Añade .returning() para obtener las filas afectadas en una sola sentencia, lo que evita realizar un 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 y onConflictDoUpdate se mapean a ON CONFLICT para los upserts. returning() es compatible con PostgreSQL, SQLite y MariaDB; MySQL no lo soporta, por lo que en ese caso debes volver a seleccionar. La API refleja deliberadamente el dialecto al que estás conectado en lugar de pretender que todas las bases de datos son idénticas.
Transacciones
Las transacciones son un callback que recibe un handle de transacción. Cada consulta en su interior debe utilizar dicho 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));
});
Lanzar un error dentro del callback revierte la transacción (rollback). Las llamadas anidadas a tx.transaction se convierten en savepoints en los dialectos que los soportan, y puedes definir un nivel de aislamiento a través del objeto de opciones. Como ocurre con cualquier base de datos, mantén el callback corto y no realices await de llamadas de red no relacionadas en su interior.
Migraciones con drizzle-kit
drizzle-kit es la CLI complementaria. Lee drizzle.config.ts y el esquema, y gestiona las migraciones.
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 el esquema con la última instantánea (snapshot), escribe un archivo SQL numerado en out y registra una instantánea en el diario. migrate aplica los archivos pendientes en orden. Ambos son seguros para producción porque el SQL es un artefacto revisable que puedes subir al repositorio.
push es el atajo para el prototipado: hace que la base de datos coincida con el esquema sin escribir archivos de migración. Es conveniente en desarrollo y arriesgado en producción por las mismas razones que cualquier comando de schema-push. pull hace lo contrario, realizando una introspección de una base de datos existente hacia TypeScript, y studio abre un navegador de datos local. Para drivers que solo soportan HTTP, como Neon, utiliza el migrador del driver en lugar de la CLI:
import { migrate } from "drizzle-orm/neon-http/migrator";
await migrate(db, { migrationsFolder: "./drizzle" });
Prepared statements
Drizzle puede preparar una consulta una sola vez y ejecutarla múltiples veces con diferentes parámetros, lo que ahorra la sobrecarga de planificación en rutas críticas (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 });
Los marcadores de posición (placeholders) se declaran con sql.placeholder("name") y se proporcionan al momento de la ejecución. Los prepared statements tienen nombre para que el driver pueda cachear el plan; recuerda que los poolers de conexiones en modo transacción, como PgBouncer, podrían no preservarlos entre diferentes conexiones.
Un ORM, muchas bases de datos
Debido a que el query builder refleja el SQL, Drizzle se adapta a cada dialecto en lugar de simplificarlos. Importas los constructores de columnas desde drizzle-orm/pg-core, drizzle-orm/mysql-core o drizzle-orm/sqlite-core, y los tipos y funciones disponibles se ajustan según la base de datos.
Esto significa que las funcionalidades específicas de PostgreSQL, como jsonb, arrays, pgEnum y columnas generadas, son ciudadanos de primera clase, mientras que MySQL y SQLite tienen sus propios equivalentes. El coste es que migrar un esquema entre dialectos requiere trabajo real: las abstracciones se comparten, pero el SQL no. Este es un compromiso deliberado para los equipos que prefieren aprovechar las capacidades reales de la base de datos en lugar de limitarse al denominador común más bajo.
Por qué se siente como SQL
La mayoría de los ORM ocultan la base de datos y exponen objetos. Drizzle hace lo contrario: te proporciona tipos basados en la base de datos que ya conoces. De esto se derivan tres consecuencias.
Primero, la depuración es más sencilla. La consulta que escribiste es la consulta que se ejecuta, por lo que leer un log de consultas lentas o una salida de EXPLAIN se mapea directamente al código. Segundo, el aprendizaje es transferible, ya que los conocimientos sobre joins, índices y ON CONFLICT se aplican sin cambios. Tercero, hay menos “magia” que pueda sorprenderte: no hay proxies de lazy-loading que disparen consultas al acceder a una propiedad, ni un identity map que decida qué está ya en memoria.
El coste es que se espera que sepas SQL. Drizzle generará felizmente una consulta ineficiente si se lo pides, y no te salvará de la falta de un índice.
Rendimiento y tamaño del bundle
Drizzle no incluye ningún motor en Rust ni un cliente generado. El runtime es TypeScript puro que permite el tree-shaking, por lo que una serverless function o un edge worker solo cargan el query builder y el driver. No hay un binario separado que empaquetar o que cause cold-starts, razón principal por la cual las plataformas de edge lo prefieren.
En el servidor, la historia es la misma que con cualquier ORM: la base de datos hace el trabajo y el rendimiento depende de cómo estructures tus consultas. Usa .returning() para evitar viajes adicionales de ida y vuelta (round trips), prepara las consultas frecuentes, selecciona solo las columnas que necesites y deja que los índices hagan su trabajo. Debido a que nada se almacena en caché ni se agrupa (batching) a tus espaldas, el rendimiento que mides es el rendimiento del SQL que escribiste.
Mejores prácticas
- Mantén
schema.tscomo la única fuente de verdad y exporta las tablas y relaciones desde un solo lugar. - Utiliza
drizzle-kit generatey haz commit del SQL; nunca permitas quepushtoque producción. - Selecciona solo las columnas que necesites y añade
.returning()en lugar de realizar una lectura posterior. - Prefiere
db.queryconwithpara lecturas anidadas y el query builder para cualquier cosa personalizada. - Envuelve las escrituras de múltiples pasos en
db.transactiony utiliza el handle detxen todo el proceso. - Usa marcadores de posición
sqly sentencias preparadas (prepared statements) en los caminos críticos (hot paths). - Adapta el driver a la plataforma y comparte una única instancia de
dbpor proceso. - Añade los índices en el esquema junto a las columnas que cubren.
- Ejecuta las migraciones en CI/CD antes de desplegar el nuevo código.
Errores comunes
- Ejecutar
drizzle-kit pushcontra una base de datos de producción y eliminar columnas. - Usar el
dbexterno dentro de una transacción en lugar deltxproporcionado. - Olvidar pasar
{ schema }adrizzle, lo que rompedb.query. - Escribir condiciones
wherecon el operador incorrecto, comoeqdonde se necesitainArray. - Seleccionar todas las columnas por defecto en rutas críticas (hot paths).
- Omitir índices en claves foráneas utilizadas en joins.
- Asumir que MySQL soporta
.returning(). - Tratar los fragmentos
sqlcomo automáticamente seguros cuando interpolan strings de usuario. - Olvidar ejecutar
generatedespués de editar el esquema y luego preguntarse por qué la migración está vacía.
Próximos pasos
Drizzle premia el conocimiento de la base de datos subyacente, así que lee la guía de PostgreSQL para aprender sobre índices, transacciones y planificación de consultas. Compáralo con Prisma, que genera un cliente a partir de un esquema, y con TypeORM, que modela las entidades como clases. Si aún estás profundizando en el lenguaje, la guía de TypeScript es la base en la que se apoyan los tres.