<!-- Canonical: https://lucioduran.com/es/blog/drizzle-orm-type-safe-sql-postgresql
Language: es
Content-SHA256: 5d15d6468b01c943878cdc6d231e620dea8157c1200f2eb21a32e97d5f89c52a
-->

Backend

# Drizzle ORM: SQL Type-Safe para PostgreSQL, Migraciones, Prepared Statements y Comparación con Prisma

2026-03-116 min lectura

Lucio Durán
Engineering Manager & AI Solutions Architect

También disponible en: [English](<https://lucioduran.com/blog/drizzle-orm-type-safe-sql-postgresql>)

## La Idea Central: SQL como Ciudadano de Primera Clase en TypeScript

La filosofía de [Drizzle](<https://orm.drizzle.team/>) 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:

```typescript
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:

```typescript
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:

```typescript
// 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

```bash
# 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

```bash
npx drizzle-kit generate
# Crea: drizzle/0003_add_post_slug.sql
```

El archivo de migración generado:

```sql
-- 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:

```sql
-- 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:

```typescript
// 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](<https://www.prisma.io/>) 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:

```typescript
// 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

```typescript
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:

1. **El query builder relacional (` db.query.* `) es menos flexible que el builder SQL-like.** Para queries complejas, siempre bajo al API ` select/from/where `. El ` findMany ` con ` with ` está genial para lecturas anidadas simples pero se rompe para cualquier cosa con joins custom o CTEs.
2. **Los mensajes de error del diff de schema pueden ser crípticos.** Cuando ` drizzle-kit push ` detecta un cambio de tipo de columna, el error a veces no te dice qué tabla o columna cambió.
3. **No tiene connection pooling built-in.** Se necesita traer el tuyo (` postgres.js ` pool, driver serverless de [Neon](<https://neon.com/>), 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.

## Preguntas frecuentes

### ¿Qué hace diferente a Drizzle ORM de Prisma?

Drizzle genera SQL a nivel TypeScript con cero overhead en runtime, las queries compilan a strings SQL planos. Prisma usa un query engine binario basado en Rust que agrega ~10-15MB a tu deployment e introduce su propia capa de planificación de queries. La API de Drizzle espeja la sintaxis SQL, mientras que Prisma es más abstraída.

### ¿Puede Drizzle ORM correr en edge runtimes como Cloudflare Workers?

Sí. Drizzle no tiene dependencias de binarios nativos, haciéndolo compatible con Cloudflare Workers, Vercel Edge Functions, Deno Deploy y cualquier otro edge runtime.

### ¿Cómo funcionan las migraciones de Drizzle?

Drizzle Kit genera archivos de migración SQL diffeando tu schema TypeScript contra el estado actual de la base o snapshots de migraciones anteriores. Obteners archivos .sql planos que es posible revisar, editar y correr con cualquier herramienta de migración.

## Tips

1. **Tip:** Utilizar ` drizzle-kit push ` para prototipar rápido y ` drizzle-kit generate ` + ` migrate ` para producción. Es común sobre-diseñar pipelines de migración custom antes de darse cuenta de que ` push ` hace exactamente lo necesario para entornos dev, diffea tu schema contra la base y aplica cambios directo.
2. **Tip:** Definir siempre el schema en un directorio separado ` schema/ ` con un archivo por tabla. Cuando el schema está en un solo ` schema.ts ` y se llega a 40+ tablas, el archivo se vuelve inmanejable y la introspección de drizzle-kit se arrastra.
3. **Tip:** Envolver queries complejas en CTEs ` db.$with() ` en vez de subqueries, el soporte de CTEs de Drizzle preserva la inferencia de tipos a lo largo de toda la cadena, y el query planner de PostgreSQL maneja los CTEs como barreras de optimización (que es frecuentemente lo que se desea).
4. **Tip:** Para Neon serverless, configurar ` prepare: false ` en la config de Drizzle. El connection pooler de Neon (pgbouncer en modo transacción) no soporta prepared statements entre conexiones, y se va a tener errores crípticos ` prepared statement does not exist ` bajo carga.

- drizzle
- orm
- postgresql
- type-safe-sql
- prisma
- kysely
- edge-runtime
- migraciones

[Volver al Blog](<https://lucioduran.com/es/blog>)
