Drizzle ORM: SQL Type-Safe para PostgreSQL — Migraciones, Prepared Statements y Comparación con Prisma
La Idea Central: SQL como Ciudadano de Primera Clase en TypeScript
La filosofía de Drizzle es simple: SQL ya es un buen lenguaje de queries. No abstraerlo — hacerlo type-safe. A continuación se presenta cómo se ve en la práctica:
import { pgTable, serial, text, timestamp, integer, boolean } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
name: text('name').notNull(),
role: text('role', { enum: ['admin', 'user', 'viewer'] }).notNull().default('user'),
createdAt: timestamp('created_at').defaultNow().notNull(),
lastLoginAt: timestamp('last_login_at'),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
content: text('content').notNull(),
published: boolean('published').notNull().default(false),
authorId: integer('author_id').notNull().references(() => users.id),
viewCount: integer('view_count').notNull().default(0),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export const comments = pgTable('comments', {
id: serial('id').primaryKey(),
body: text('body').notNull(),
postId: integer('post_id').notNull().references(() => posts.id),
authorId: integer('author_id').notNull().references(() => users.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
comments: many(comments),
}));
export const postsRelations = relations(posts, ({ one, many }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
comments: many(comments),
}));
Ahora observar qué pasa cuando hacés queries. Cada campo, cada join, cada condición está completamente type-checked:
import { drizzle } from 'drizzle-orm/neon-http';
import { neon } from '@neondatabase/serverless';
import { eq, and, gt, desc, sql, count } from 'drizzle-orm';
import * as schema from './schema';
const queryClient = neon(process.env.DATABASE_URL!);
const db = drizzle(queryClient, { schema });
// Select simple — el tipo de retorno se infiere exactamente
const activeUsers = await db
.select({
id: schema.users.id,
email: schema.users.email,
name: schema.users.name,
})
.from(schema.users)
.where(eq(schema.users.role, 'admin'))
.orderBy(desc(schema.users.createdAt))
.limit(10);
// TypeScript sabe: activeUsers es Array<{ id: number; email: string; name: string }>
// Intentá acceder a activeUsers[0].role — error de compilación! No fue seleccionado.
Esta es la diferencia clave con Prisma. En Prisma, si utilizars select, teners type safety. Pero queries complejas con escape hatches de raw SQL pierden información de tipos. En Drizzle, incluso fragmentos de SQL raw pueden llevar información de tipos:
// Query compleja con subquery, agregación y raw SQL type-safe
const topAuthors = await db
.select({
authorId: schema.posts.authorId,
authorName: schema.users.name,
postCount: count(schema.posts.id).as('post_count'),
totalViews: sql<number>`sum(${schema.posts.viewCount})`.as('total_views'),
avgViews: sql<number>`avg(${schema.posts.viewCount})::integer`.as('avg_views'),
})
.from(schema.posts)
.innerJoin(schema.users, eq(schema.posts.authorId, schema.users.id))
.where(eq(schema.posts.published, true))
.groupBy(schema.posts.authorId, schema.users.name)
.having(gt(count(schema.posts.id), 5))
.orderBy(desc(sql`sum(${schema.posts.viewCount})`))
.limit(20);
Ese template literal tag sql<number> es genial. Escribirs SQL real pero anotás el tipo de retorno, y Drizzle lo propaga por toda la inferencia de tipos de la query.
Estrategias de Migración: Donde Drizzle Kit Brilla
Drizzle Kit es la herramienta CLI compañera para manejo de schemas. Tiene dos modos:
drizzle-kit push — El Modo Desarrollo
# Lee tus archivos de schema, compara contra la base real,
# genera y aplica ALTER statements directamente
npx drizzle-kit push
# Output:
# [✓] Leyendo archivos de schema...
# [✓] Conectando a la base...
# [i] Cambios detectados:
# ALTER TABLE "posts" ADD COLUMN "slug" text;
# CREATE UNIQUE INDEX "posts_slug_idx" ON "posts" ("slug");
# [✓] 2 cambios aplicados
Esto es para iteración rápida. Cambiás tu archivo TypeScript de schema, ejecutars push, y tu base coincide con. Sin archivos de migración, sin versionado.
drizzle-kit generate + migrate — El Modo Producción
npx drizzle-kit generate
# Crea: drizzle/0003_add_post_slug.sql
El archivo de migración generado:
-- drizzle/0003_add_post_slug.sql
ALTER TABLE "posts" ADD COLUMN "slug" text;--> statement-breakpoint
CREATE UNIQUE INDEX "posts_slug_idx" ON "posts" ("slug");
Es posible editar estos archivos — agregar migraciones de datos, wrappear en transacciones:
-- drizzle/0003_add_post_slug.sql (editado)
BEGIN;
ALTER TABLE "posts" ADD COLUMN "slug" text;--> statement-breakpoint
-- Migración de datos: generar slugs desde los títulos
UPDATE "posts" SET "slug" = lower(
regexp_replace(
regexp_replace("title", '[^a-zA-Z0-9\s-]', '', 'g'),
'\s+', '-', 'g'
)
);--> statement-breakpoint
ALTER TABLE "posts" ALTER COLUMN "slug" SET NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "posts_slug_idx" ON "posts" ("slug");--> statement-breakpoint
COMMIT;
Prepared Statements y la Historia del Edge Runtime
Este es el punto donde la arquitectura de Drizzle rinde dividendos. Como no hay binario de query engine, Drizzle corre en cualquier lugar donde corra JavaScript:
// Cloudflare Workers — funciona directo
import { drizzle } from 'drizzle-orm/neon-http';
import { neon } from '@neondatabase/serverless';
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const sql = neon(env.DATABASE_URL);
const db = drizzle(sql);
// Prepared statement — compilado una vez, ejecutado muchas
const getUser = db
.select()
.from(schema.users)
.where(eq(schema.users.id, sql.placeholder('userId')))
.prepare('get_user_by_id');
const user = await getUser.execute({ userId: 42 });
return Response.json(user);
}
};
La Comparación Honesta con Prisma
Prisma es una buena herramienta con años de validación en producción. Pero este es el punto donde los dos genuinamente difieren:
Tiempo de Cold Start
Esto es medible y significativo para serverless:
Cold start de Prisma (AWS Lambda, Node.js 20):
- Carga binario query engine: ~180ms
- Parseo de schema: ~45ms
- Primera query: ~80ms
Total: ~305ms
Cold start de Drizzle (mismo entorno):
- Import de módulo: ~12ms
- Primera query: ~75ms
Total: ~87ms
Ese es ~220ms de diferencia son el query engine de Prisma cargando. En Cloudflare Workers con presupuestos de cold start más estrictos, es todavía más doloroso.
Generación de Queries
Prisma genera su propio plan de query y lo traduce a SQL. A veces produce queries subóptimas:
// Prisma genera: múltiples queries separadas (potencial 1+N)
// SELECT * FROM "posts" WHERE "published" = true
// SELECT * FROM "users" WHERE "id" IN (...)
// SELECT * FROM "comments" WHERE "post_id" IN (...)
// SELECT * FROM "users" WHERE "id" IN (...)
// Drizzle genera: lateral joins donde sea posible
// SELECT ... FROM "posts"
// LEFT JOIN LATERAL (
// SELECT ... FROM "comments"
// LEFT JOIN "users" ON ...
// WHERE "comments"."post_id" = "posts"."id"
// ORDER BY "created_at" DESC LIMIT 5
// ) ON true
// LEFT JOIN "users" ON "users"."id" = "posts"."author_id"
// WHERE "posts"."published" = true
El enfoque de lateral join es una sola query que PostgreSQL puede optimizar holísticamente.
Transacciones y Manejo de Errores
const newPost = await db.transaction(async (tx) => {
const [author] = await tx
.select()
.from(schema.users)
.where(eq(schema.users.id, authorId))
.for('update'); // SELECT FOR UPDATE — lock a nivel fila
if (!author) throw new Error('Autor no encontrado');
const [post] = await tx
.insert(schema.posts)
.values({
title,
content,
authorId: author.id,
published: false,
})
.returning();
await tx
.update(schema.users)
.set({ lastLoginAt: new Date() })
.where(eq(schema.users.id, authorId));
return post;
});
// La transacción auto-commitea en éxito, auto-rollbackea en throw
Limitaciones Notables
Después de uso extendido en producción con Drizzle, las limitaciones notables son:
-
El query builder relacional (
db.query.*) es menos flexible que el builder SQL-like. Para queries complejas, siempre bajo al APIselect/from/where. ElfindManyconwithestá genial para lecturas anidadas simples pero se rompe para cualquier cosa con joins custom o CTEs. -
Los mensajes de error del diff de schema pueden ser crípticos. Cuando
drizzle-kit pushdetecta un cambio de tipo de columna, el error a veces no te dice qué tabla o columna cambió. -
No tiene connection pooling built-in. Se necesita traer el tuyo (
postgres.jspool, driver serverless de Neon, etc.).
Pero estos son cortes de papel comparados con los beneficios fundamentales: cero dependencias de binarios, type safety genuino a nivel SQL, y la libertad de desplegar en cualquier lugar donde corra JavaScript. Drizzle representa la dirección que el espacio de ORMs necesitaba tomar.