🗄️ Bases de datos en el ecosistema TypeScript
PostgreSQL, Prisma, Drizzle y Kysely: ORMs y query builders type-safe, migraciones, transacciones, índices, errores N+1 y un CRUD completo con Prisma.
Bases de datos en el ecosistema TypeScript
Una base de datos relacional es el almacén de verdad de casi todo backend moderno. En el ecosistema TypeScript la forma de acceder a ella pasó de escribir SQL a mano, a usar query builders y ORMs que generan tipos estáticos a partir del esquema. En este artículo dominas PostgreSQL, los tres accesos principales —Prisma, Drizzle y Kysely— y los problemas que debes evitar (N+1, migraciones rotas, transacciones mal manejadas).
PostgreSQL: los conceptos que necesitas
PostgreSQL (Postgres) es un motor relacional de código abierto con soporte ACID completo. Antes de tocar código, estas son las piezas que usas a diario:
| Concepto | Qué es |
|---|---|
| Tabla | Conjunto de filas con columnas tipadas |
| Columna | Un campo con un tipo (SERIAL, TEXT, INTEGER, TIMESTAMPTZ, UUID, JSONB) |
| Clave primaria (PK) | Identifica cada fila de forma única |
| Clave foránea (FK) | Referencia una PK de otra tabla (relaciones) |
| Índice | Estructura (B-tree) que acelera búsquedas |
| Transacción | Grupo de operaciones que se aplican todas o ninguna |
CREATE TABLE usuarios (
id SERIAL PRIMARY KEY,
nombre TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
creado_en TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE posts (
id SERIAL PRIMARY KEY,
titulo TEXT NOT NULL,
autor_id INTEGER NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE,
publicado BOOLEAN NOT NULL DEFAULT false
);
Conexión y pooling
Conectar Node a Postgres se hace con un driver (pg) que mantiene un pool de conexiones. Abrir una conexión por request es caro: el pool reutiliza conexiones y las pide bajo demanda.
import { Pool } from "pg";
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // conexiones simultáneas máximas
idleTimeoutMillis: 30_000,
});
const { rows } = await pool.query("SELECT * FROM usuarios WHERE id = $1", [1]);
El $1 es un parámetro posicional que previene inyección SQL: nunca interpoles strings en las queries.
⚠️ En serverless (Vercel, Cloudflare) el pool no funciona como en un proceso largo: cada invocación puede abrir conexiones y agotar el límite de Postgres. Usa un pooler (PgBouncer/Supabase Pooler) o reutiliza una conexión por warm function.
Prisma: el ORM con migraciones integradas
Prisma es el ORM más popular del ecosistema. Su modelo mental tiene tres piezas: Prisma Schema (declaras el modelo), Prisma Client (el acceso tipado) y Prisma Migrate (migraciones de base de datos).
El schema
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Usuario {
id Int @id @default(autoincrement())
email String @unique
nombre String
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
titulo String
contenido String?
publicado Boolean @default(false)
autor Usuario @relation(fields: [autorId], references: [id])
autorId Int
}
@relation(fields: [autorId], references: [id]) define la clave foránea; el campo posts Post[] del lado inverso es solo una relación virtual para navegar.
Migraciones y el Client
npx prisma migrate dev --name crea_usuarios_posts # genera y aplica la migración
npx prisma generate # regenera el Client (tipos)
npx prisma studio # UI para inspeccionar datos
El PrismaClient expone operaciones 100% tipadas: el compilador conoce los modelos, sus campos y el tipo de retorno de cada query.
Queries: create, find, update, delete
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
// create
const usuario = await prisma.usuario.create({
data: { email: "ana@dev.io", nombre: "Ana" },
});
// find con filtros y relaciones (eager loading con include)
const conPosts = await prisma.usuario.findUnique({
where: { email: "ana@dev.io" },
include: { posts: { where: { publicado: true } } },
});
// update / delete
await prisma.usuario.update({
where: { id: usuario.id },
data: { nombre: "Ana García" },
});
await prisma.usuario.delete({ where: { id: usuario.id } });
// paginación
const pagina = await prisma.post.findMany({
skip: 10,
take: 10,
orderBy: { id: "desc" },
});
Relaciones anidadas
// Crear un usuario con sus posts en una sola llamada (nested write)
const ana = await prisma.usuario.create({
data: {
email: "ana@dev.io",
nombre: "Ana",
posts: {
create: [{ titulo: "Primer post" }, { titulo: "Segundo post" }],
},
},
include: { posts: true },
});
Transacciones
Cuando varias operaciones deben ser atómicas (todas o ninguna), usa una transacción:
// Interactiva: control total del orden
await prisma.$transaction(async (tx) => {
await tx.usuario.update({ where: { id: 1 }, data: { saldo: { decrement: 100 } } });
await tx.post.create({ data: { titulo: "Pago realizado", autorId: 1 } });
// si algo lanza, se hace rollback de TODO
});
// Array: batched (todas o ninguna)
await prisma.$transaction([
prisma.post.update({ where: { id: 1 }, data: { publicado: true } }),
prisma.post.update({ where: { id: 2 }, data: { publicado: true } }),
]);
Dentro de un callback $transaction, usa el cliente tx (no el prisma global) para que todas las llamadas queden dentro de la misma transacción.
Drizzle: schema en TypeScript, query type-safe
Drizzle es un ORM más ligero que Prisma: el esquema se define en TypeScript puro (sin un lenguaje DSL propio) y las queries son cercanas a SQL, pero con tipos estáticos. Es la opción favorita para quien viene de SQL y quiere menos magia.
// src/db/schema.ts
import { pgTable, serial, text, integer, boolean, timestamp } from "drizzle-orm/pg-core";
export const usuarios = pgTable("usuarios", {
id: serial("id").primaryKey(),
email: text("email").notNull().unique(),
nombre: text("nombre").notNull(),
creadoEn: timestamp("creado_en").defaultNow(),
});
export const posts = pgTable("posts", {
id: serial("id").primaryKey(),
titulo: text("titulo").notNull(),
autorId: integer("autor_id").references(() => usuarios.id),
publicado: boolean("publicado").default(false),
});
export type Usuario = typeof usuarios.$inferSelect; // fila seleccionada
export type NuevoUsuario = typeof usuarios.$inferInsert; // fila insertada
Queries type-safe
import { db } from "./db";
import { usuarios } from "./schema";
import { eq } from "drizzle-orm";
const todos = await db.select().from(usuarios); // Usuario[]
const ana = await db
.select()
.from(usuarios)
.where(eq(usuarios.email, "ana@dev.io"))
.limit(1);
await db.insert(usuarios).values({ email: "ana@dev.io", nombre: "Ana" });
await db.update(usuarios).set({ nombre: "Ana G." }).where(eq(usuarios.id, 1));
await db.delete(usuarios).where(eq(usuarios.id, 1));
Migraciones con drizzle-kit
npx drizzle-kit generate # genera SQL a partir del schema TS
npx drizzle-kit migrate # aplica las migraciones
npx drizzle-kit studio # UI para explorar datos
Drizzle soporta relational queries con un API parecida a Prisma:
import { db } from "./db";
import { usuarios, posts } from "./schema";
const anaConPosts = await db.query.usuarios.findFirst({
where: (u, { eq }) => eq(u.email, "ana@dev.io"),
with: { posts: true },
});
Kysely: query builder type-safe
Kysely no es un ORM: es un query builder que mantiene los tipos derivados del propio schema de la base de datos. Tú escribes SQL casi literal, pero con columnas y tablas comprobadas en tiempo de compilación.
import { Kysely, PostgresDialect } from "kysely";
import { Pool } from "pg";
interface Database {
usuarios: { id: number; email: string; nombre: string };
posts: { id: number; titulo: string; autorId: number; publicado: boolean };
}
export const db = new Kysely<Database>({
dialect: new PostgresDialect({ pool: new Pool({ connectionString: process.env.DATABASE_URL }) }),
});
// Kysely valida el nombre de tablas, columnas y el tipo de los valores
const ana = await db
.selectFrom("usuarios")
.select(["id", "email"])
.where("email", "=", "ana@dev.io")
.executeTakeFirst();
await db.insertInto("usuarios").values({ email: "x@dev.io", nombre: "X" }).execute();
💡 Para generar los tipos desde una base existente, Kysely ofrece
kysely-codegen, que lee el esquema de Postgres y genera la interfazDatabase.
Comparativa de ORMs
| Aspecto | Prisma | Drizzle | Kysely |
|---|---|---|---|
| Esquema | DSL propio (.prisma) |
TypeScript puro | Tipo TS, SQL literal |
| Migraciones | Integradas (Prisma Migrate) | drizzle-kit |
Vía kysely-ctl/node-pg-migrate |
| Curva de aprendizaje | Media-alta (modelo propio) | Media (SQL familiar) | Media-baja |
| Rendimiento | Adecuado | Muy alto (relacional queries) | Alto |
| Ecosistema | Prisma Studio, generadores | Drizzle Studio | Minimalista |
⚠️ La regla de oro: un ORM no elimina la necesidad de saber SQL. Cuando una query es compleja (window functions, CTE, joins raros), todos permiten
$queryRaw/sql— úsalo sin miedo. El tipo llega donde el SQL es trivial.## Índices y el problema N+1
Índices
Un índice acelera las búsquedas a costa de espacio y de escrituras más lentas. Crea índices en las columnas que filtras u ordenas con frecuencia:
model Post {
id Int @id @default(autoincrement())
titulo String
autorId Int
publicado Boolean @default(false)
@@index([autorId, publicado]) // compuesto: busca por autor y filtro
}
💡 En una FK, Postgres no crea índice automáticamente. Si filtras/joineas por esa columna, añade un índice explícito.
El error N+1
El problema N+1: ejecutas una query para listar N filas y luego N queries para traer la relación de cada una. Con 100 usuarios, son 101 queries:
// ❌ N+1: una query por cada post (lazy loading por fila)
for (const u of await prisma.usuario.findMany()) {
const posts = await prisma.post.findMany({ where: { autorId: u.id } });
}
La solución es eager loading (traer la relación de una sola vez) con include:
// ✅ eager loading: una query con JOIN
const usuarios = await prisma.usuario.findMany({ include: { posts: true } });
En Drizzle: with: { posts: true }. En Kysely/SQL crudo: un JOIN explícito.
⚠️ Detecta el N+1 con logs: habilita
log: ["query"]en el PrismaClient y observa el número deSELECT. Si crece linealmente con las filas, es N+1.
Ejemplo completo: CRUD con Prisma en una API
Combinamos Fastify + Prisma + zod en una API de posts con autor:
import Fastify from "fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod";
const app = Fastify({ logger: true });
const prisma = new PrismaClient({ log: ["query", "error"] });
const crearPostSchema = z.object({
titulo: z.string().min(3).max(200),
contenido: z.string().optional(),
autorEmail: z.string().email(),
});
app.get("/posts", async () => {
return prisma.post.findMany({ include: { autor: true }, orderBy: { id: "desc" } });
});
app.get("/posts/:id", async (req, reply) => {
const post = await prisma.post.findUnique({
where: { id: Number(req.params.id) },
include: { autor: true },
});
if (!post) return reply.code(404).send({ error: "post no encontrado" });
return post;
});
app.post("/posts", async (req, reply) => {
const parsed = crearPostSchema.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues });
const autor = await prisma.usuario.findUnique({ where: { email: parsed.data.autorEmail } });
if (!autor) return reply.code(404).send({ error: "autor no existe" });
const post = await prisma.post.create({
data: { titulo: parsed.data.titulo, contenido: parsed.data.contenido, autorId: autor.id },
include: { autor: true },
});
return reply.code(201).send(post);
});
app.put("/posts/:id", async (req, reply) => {
const post = await prisma.post.update({
where: { id: Number(req.params.id) },
data: { titulo: req.body.titulo, contenido: req.body.contenido },
include: { autor: true },
});
return post;
});
app.delete("/posts/:id", async (req, reply) => {
await prisma.post.delete({ where: { id: Number(req.params.id) } });
return reply.code(204).send();
});
const start = async () => {
try {
await app.listen({ port: 3000 });
} catch (err) {
app.log.error(err);
await prisma.$disconnect();
process.exit(1);
}
};
start();
Flujo completo con migración:
npx prisma migrate dev --name init # crea tablas
npx prisma generate # genera el client tipado
npm run dev # levanta la API
curl -X POST localhost:3000/posts -H "Content-Type: application/json" \
-d '{"titulo":"Hola","contenido":"mundo","autorEmail":"ana@dev.io"}'
💡 Cierra siempre el cliente al apagar el proceso (
prisma.$disconnect()) para liberar el pool y evitar conexiones colgadas.
Buenas prácticas
- Valida la entrada en el borde: zod antes de tocar la DB.
- Usa transacciones cuando una operación toque varias tablas.
- Eager loading para evitar N+1; nunca pongas la query en un loop.
- Índices en lo que filtras; mide con
EXPLAIN ANALYZE. - Migraciones versionadas en el repo, aplicadas en CI antes del deploy.
Para profundizar
- Prisma — Documentación oficial: schema, client, migrate y conceptos de relaciones.
- Prisma — Transacciones: transacciones interactivas y batched en profundidad.
- Drizzle — Documentación oficial: schema en TS, relational queries y drizzle-kit.
- Kysely — Documentación: query builder type-safe sobre dialectos SQL.
- PostgreSQL — Documentación: la referencia completa del motor y sus índices.