🔎 Buscar

🗄️ 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.

Wiki / Apuntes📖 Contenido

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 interfaz Database.

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 de SELECT. 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

  1. Valida la entrada en el borde: zod antes de tocar la DB.
  2. Usa transacciones cuando una operación toque varias tablas.
  3. Eager loading para evitar N+1; nunca pongas la query en un loop.
  4. Índices en lo que filtras; mide con EXPLAIN ANALYZE.
  5. Migraciones versionadas en el repo, aplicadas en CI antes del deploy.

Para profundizar

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.