¿Qué es Prisma?
Prisma es un ORM schema-first para TypeScript y Node.js. Describes tus datos una sola vez en schema.prisma, ejecutas un generador y obtienes un cliente cuyos métodos y tipos de retorno provienen directamente de ese esquema. No hay decoradores, ni clases de repositorio, ni cadenas de SQL escritas a mano para los casos comunes.
La propuesta es que el esquema de la base de datos se convierte en un único archivo declarativo que los humanos leen y las herramientas consumen. A partir de él, Prisma produce un cliente tipado que importas en tu código, migraciones SQL que puedes revisar y hacer commit, y una interfaz de estudio (studio UI) para explorar los datos. Prisma es compatible con PostgreSQL, MySQL, MariaDB, SQLite, SQL Server, CockroachDB y MongoDB; el cliente generado traduce cada llamada a SQL o al protocolo de MongoDB antes de mapear las filas nuevamente a objetos simples.
Si has utilizado anteriormente un ORM estilo ActiveRecord, el cambio de mentalidad es que Prisma es schema-first y client-generated, en lugar de ser class-first y runtime-reflective. Esa única decisión explica la mayoría de sus fortalezas y la mayoría de sus costos.
El esquema es la fuente de verdad
Todo comienza en schema.prisma. Contiene tres tipos de bloques: un datasource (donde reside la base de datos), un generator (qué emitir) y un model por cada tabla.
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(uuid())
email String @unique
name String?
posts Post[]
createdAt DateTime @default(now())
}
Vale la pena interiorizar algunas convenciones. Un campo que termina en ? es nullable, mientras que un tipo de lista como Post[] es una relación en lugar de una columna. Los atributos comienzan con @ para los campos y @@ para los bloques. @id marca la clave primaria, @unique crea un índice único, @default(...) proporciona un valor, y @map y @@map renombran la columna o tabla subyacente cuando no coincide con tu modelo.
El modelo anterior se mapea a una tabla User con las columnas id, email, name y createdAt, además de una relación virtual posts que Prisma resuelve mediante un join o una segunda consulta.
Generando y utilizando el cliente
El cliente de Prisma es código generado. Después de editar el esquema, ejecuta:
pnpm prisma generate
Esto lee schema.prisma y escribe un cliente en node_modules/.prisma/client, o en una carpeta que elijas utilizando el generador más reciente prisma-client. Luego, lo instancias una vez y lo reutilizas:
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const user = await prisma.user.findUnique({
where: { email: "[email protected]" },
});
Debido a que el cliente es generado, tanto prisma.user como la estructura de user son conocidos por el comprobador de tipos. Si renombras un campo en el esquema y regeneras el cliente, cualquier uso desactualizado fallará al compilar. Ese ciclo de retroalimentación es la razón principal por la cual los equipos adoptan Prisma.
En entornos serverless o con hot-reloading, evita crear un nuevo cliente por cada solicitud o recarga. En su lugar, adjunta uno al objeto global:
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
Lectura de datos
Prisma expone un método por cada forma de lectura. findUnique obtiene una única fila mediante un campo único, findFirst obtiene una fila que coincida con un filtro arbitrario y findMany devuelve una lista.
const post = await prisma.post.findUnique({ where: { id: 42 } });
const latest = await prisma.post.findFirst({
where: { published: true },
orderBy: { createdAt: "desc" },
});
const posts = await prisma.post.findMany({
where: {
published: true,
title: { contains: "prisma", mode: "insensitive" },
authorId: { in: [userId] },
},
orderBy: { createdAt: "desc" },
take: 20,
skip: 0,
});
Los operadores de filtrado utilizan nombres en lugar de símbolos: equals, not, in, notIn, lt, lte, gt, gte, contains, startsWith, endsWith y mode. Combínalos con arrays AND, OR y NOT:
const posts = await prisma.post.findMany({
where: {
OR: [
{ title: { contains: "orm" } },
{ author: { email: { endsWith: "@example.com" } } },
],
NOT: { published: false },
},
});
take y skip implementan la paginación por desplazamiento (offset pagination). Para conjuntos de resultados grandes, es preferible usar la paginación por cursor (cursor pagination), que pagina desde la última fila en lugar de contar desde el principio:
const page = await prisma.post.findMany({
take: 20,
skip: 1,
cursor: { id: lastSeenId },
orderBy: { id: "asc" },
});
findUnique no aceptará un where que no sea único; para esos casos, utiliza findFirst. Las variantes findUniqueOrThrow y findFirstOrThrow lanzan un error en lugar de devolver null, lo que permite eliminar una ramificación condicional cuando la fila debe existir obligatoriamente.
Escritura de datos
Las operaciones de creación, actualización y eliminación están tipadas de la misma manera. create, update, upsert y delete operan sobre una sola fila, mientras que createMany, updateMany y deleteMany operan sobre conjuntos.
const user = await prisma.user.create({
data: { email: "[email protected]", name: "Ada" },
});
await prisma.user.update({
where: { id: user.id },
data: { name: "Ada Lovelace" },
});
await prisma.user.upsert({
where: { email: "[email protected]" },
update: { name: "Ada Lovelace" },
create: { email: "[email protected]", name: "Ada" },
});
await prisma.user.delete({ where: { id: user.id } });
Para inserciones masivas, createMany emite un único INSERT y es drásticamente más rápido que iterar sobre create:
await prisma.post.createMany({
data: [
{ title: "Hello", authorId: user.id },
{ title: "World", authorId: user.id },
],
skipDuplicates: true,
});
La desventaja es que createMany no puede escribir relaciones anidadas; está diseñado únicamente para filas planas. updateMany y deleteMany aceptan los mismos filtros que findMany, por lo que la ausencia de un where realmente afecta a todas las filas.
Relaciones, include y select
Las relaciones se declaran en ambos lados. User.posts es una lista y Post.author es un valor único, vinculados mediante @relation(fields: [authorId], references: [id]) en el lado propietario.
Por defecto, Prisma devuelve únicamente las columnas escalares. Para cargar una relación, debes añadir include:
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
posts: {
where: { published: true },
orderBy: { createdAt: "desc" },
take: 10,
},
},
});
select es la herramienta más precisa: elige exactamente qué campos devolver, tanto para el modelo como para las relaciones anidadas.
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
posts: {
select: { title: true },
where: { published: true },
},
_count: { select: { posts: true } },
},
});
No puedes combinar select y include en el mismo nivel, ya que select ya responde a la pregunta de qué devolver. Utiliza select cuando un endpoint tenga una estructura de respuesta fija, y include cuando realmente necesites el registro relacionado completo. Devolver filas enteras para luego filtrarlas en JavaScript desperdicia ancho de banda y memoria, por lo que select evita una regresión común.
Escrituras anidadas
Una de las mejores características de Prisma es la capacidad de escribir un padre y sus hijos en una sola llamada. Las operaciones anidadas create, connect, update y delete se ejecutan todas dentro de una transacción implícita.
const user = await prisma.user.create({
data: {
email: "[email protected]",
posts: {
create: [
{ title: "First post" },
{ title: "Second post", published: true },
],
},
},
include: { posts: true },
});
Para conectar filas existentes en lugar de crearlas se utiliza connect, y para desconectar una relación se utiliza disconnect:
await prisma.post.update({
where: { id: postId },
data: {
author: { connect: { email: "[email protected]" } },
},
});
Las escrituras anidadas mantienen la consistencia de los datos relacionados sin que tengas que abrir una transacción manualmente, que es exactamente el tipo de gestión que un ORM debería manejar.
Migraciones en desarrollo y producción
Prisma Migrate convierte la diferencia entre tu schema y tu base de datos en archivos SQL versionados dentro de prisma/migrations.
pnpm prisma migrate dev --name add_posts
pnpm prisma migrate deploy
pnpm prisma migrate status
migrate dev es un comando de desarrollo. Compara el schema con la base de datos, escribe una nueva migración, la aplica y regenera el client. También utiliza una shadow database para detectar el drift, por lo que el rol de desarrollo necesita permisos para crear y eliminar bases de datos. Si se encuentra un drift, es posible que sugiera resetear la base de datos, lo cual elimina los datos.
migrate deploy es el comando de producción. Aplica las migraciones pendientes y nada más: sin generación, sin shadow database y sin resets. Ejecútalo en tu pipeline de despliegue antes de que el nuevo código comience a recibir tráfico.
Para prototipos desechables, prisma db push omite completamente el historial de migraciones y fuerza a la base de datos a coincidir con el schema:
pnpm prisma db push
db push es rápido y conveniente, pero no deja un rastro de auditoría y puede eliminar columnas o tablas para que la base de datos se ajuste al schema. Nunca lo apuntes a producción. Otros comandos útiles son prisma migrate reset para eliminar, recrear y volver a sembrar (seed) una base de datos de desarrollo, y prisma migrate diff para mostrar qué cambiaría sin llegar a aplicarlo.
Seeding
Los datos de seed deben ir en prisma/seed.ts. Regístralo en package.json para que Prisma sepa cómo ejecutarlo:
{
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
await prisma.user.upsert({
where: { email: "[email protected]" },
update: {},
create: {
email: "[email protected]",
name: "Ada",
posts: { create: [{ title: "Welcome" }] },
},
});
}
main()
.then(() => prisma.$disconnect())
.catch(async (error) => {
console.error(error);
await prisma.$disconnect();
process.exit(1);
});
Ejecútalo con pnpm prisma db seed. Debido a que el seeding se ejecuta después de migrate dev y migrate reset, una base de datos recién creada nunca estará vacía, lo que hace que el onboarding y las pruebas sean predecibles.
Transacciones
Prisma dispone de dos API de transacciones. La forma secuencial recibe un array de operaciones y las ejecuta en orden:
const [debit, credit] = await prisma.$transaction([
prisma.account.update({
where: { id: 1 },
data: { balance: { decrement: 5000 } },
}),
prisma.account.update({
where: { id: 2 },
data: { balance: { increment: 5000 } },
}),
]);
La forma interactiva te proporciona un cliente de transacción y te permite tomar decisiones basadas en los resultados:
await prisma.$transaction(async (tx) => {
const sender = await tx.account.findUniqueOrThrow({ where: { id: 1 } });
if (sender.balance < 5000) throw new Error("insufficient_funds");
await tx.account.update({
where: { id: 1 },
data: { balance: { decrement: 5000 } },
});
await tx.account.update({
where: { id: 2 },
data: { balance: { increment: 5000 } },
});
});
Utiliza tx para cada consulta dentro de una transacción interactiva; si usas el cliente prisma externo, saldrás de la transacción. Puedes ajustar el comportamiento con maxWait, que controla cuánto tiempo esperar por una conexión, y timeout, que limita la duración máxima de la transacción, además de definir el nivel de aislamiento con isolationLevel. Mantén las transacciones cortas y nunca dejes una abierta durante una llamada HTTP o una interacción del usuario.
SQL puro cuando lo necesites
El query builder cubre la mayoría de las lecturas, pero las consultas de reportes y las funcionalidades específicas de la base de datos a veces requieren SQL. $queryRaw es una tagged template que parametriza los valores por ti:
import { Prisma } from "@prisma/client";
const rows = await prisma.$queryRaw<
{ day: Date; revenue: bigint }[]
>(Prisma.sql`
SELECT date_trunc('day', created_at) AS day,
sum(total_cents) AS revenue
FROM orders
WHERE status = 'paid'
GROUP BY 1
ORDER BY 1 DESC
`);
Usa $queryRaw para SELECT y $executeRaw para escrituras. Ambos aceptan tagged templates, lo que evita la inyección de SQL ya que los valores interpolados se convierten en parámetros vinculados. $queryRawUnsafe y $executeRawUnsafe existen para SQL genuinamente dinámico y deben considerarse peligrosos: nunca interpoles entradas de usuario en ellos. Prisma.sql y Prisma.join te permiten componer fragmentos manteniendo intacta la parametrización.
Pooling, serverless y Accelerate
Cada instancia de PrismaClient posee un pool de conexiones. En un servidor de larga duración, esto es exactamente lo que se busca. En una plataforma serverless, cada instancia de función crea su propio cliente y, por lo tanto, su propio pool; un pico de tráfico puede agotar las max_connections de la base de datos en cuestión de segundos.
La primera herramienta es la cadena de conexión. Limita el pool y el tiempo de espera:
DATABASE_URL="postgresql://user:pass@host:5432/shop?connection_limit=5&pool_timeout=10"
En funciones donde cada instancia maneja una sola solicitud a la vez, connection_limit=1 suele ser lo correcto. Si utilizas PgBouncer, añade ?pgbouncer=true para que Prisma deje de usar prepared statements que el transaction pooling no puede preservar. Si no puedes modificar los límites de conexión de la base de datos, Prisma Accelerate actúa como un pooler gestionado y caché; envuelves el cliente con su extensión y rutas las consultas a través de un endpoint global. El antiguo Data Proxy resolvía el mismo problema y ha sido sustituido por Accelerate.
Sea cual sea tu elección, la regla es la misma: reutiliza un único cliente por proceso y evita que el número de instancias de la aplicación multiplique las conexiones más allá de lo que la base de datos puede soportar.
La cuestión del N+1
Un problema de N+1 ocurre cuando obtienes una lista y luego ejecutas una consulta por cada fila para cargar una relación. Prisma evita la forma clásica porque include y select cargan las relaciones como parte de la misma llamada: para un findMany con una relación, ejecuta una consulta para los padres y otra para los hijos, no una por cada padre.
Tú mismo puedes reintroducir el problema si utilizas un bucle:
const users = await prisma.user.findMany();
for (const user of users) {
// One query per user: this is the N+1.
const posts = await prisma.post.findMany({ where: { authorId: user.id } });
}
En su lugar, carga la relación en la consulta original:
const users = await prisma.user.findMany({
include: { posts: true },
});
Para lecturas profundamente anidadas, la estrategia predeterminada de Prisma ejecuta una consulta por cada nivel de relación. En los casos donde una única consulta con join sea materialmente más rápida, la opción relationLoadStrategy: "join" le indica a Prisma que utilice un LEFT JOIN en su lugar. Mide el rendimiento antes de cambiar, ya que ambas estrategias intercambian la cantidad de viajes de ida y vuelta (round trips) por la duplicación de columnas del padre.
Mejores prácticas
- Trata a
schema.prismacomo la fuente de verdad y modifícalo a través de migraciones, nunca manualmente. - Instancia
PrismaClientuna sola vez por proceso y reutilízalo; protege el hot reload con un global. - Usa
selectpara formas de respuesta fijas yincludesolo cuando necesites los registros completos. - Prefiere la paginación por cursor sobre offsets grandes de
skip. - Usa
createManypara inserciones masivas y escrituras anidadas para filas relacionadas. - Mantén las transacciones interactivas cortas y usa siempre el cliente
txdentro de ellas. - Recurre a
$queryRawcon tagged templates, nunca a las variantes Unsafe, cuando necesites SQL. - Configura
connection_limity, en entornos serverless, utiliza un pooler como Accelerate o PgBouncer. - Haz commit de las carpetas de migraciones y ejecuta
migrate deployen CI/CD en lugar dedb push.
Errores comunes
- Ejecutar
prisma db pushcontra producción y perder columnas. - Crear un nuevo
PrismaClientpor cada solicitud y agotar las conexiones. - Olvidar
prisma generatedespués de un cambio de esquema y luego preguntarse por qué los tipos están desactualizados. - Obtener filas completas con
includey filtrar los campos en JavaScript. - Usar el cliente externo dentro de una transacción interactiva, lo que provoca que se salga de la transacción silenciosamente.
- Ignorar
whereenupdateManyodeleteManyy actualizar todas las filas. - Asumir que
findUniqueacepta cualquier filtro; requiere un campo único. - Interpolar la entrada del usuario en
$queryRawUnsafe. - Mantener una transacción abierta mientras se espera la respuesta de una API externa.
Próximos pasos
Prisma es una de las opciones para interactuar con una base de datos relacional desde TypeScript. Compáralo con Drizzle, que mantiene el SQL visible y prescinde del cliente generado, y con TypeORM, el enfoque basado en clases y decoradores que es anterior a ambos. Debajo de cada uno de ellos está la base de datos en sí, por lo que la guía de PostgreSQL es un conocimiento profundo que vale la pena independientemente del ORM. Si aún estás eligiendo un runtime para el servidor que alojará todo esto, comienza con Node.js.