Patrones de Prisma
Patrones de producción y trampas no obvias para Prisma ORM en backends TypeScript.
Verifica tu versión antes de aplicar patrones. La superficie de la API de Prisma ha evolucionado en todas las versiones principales:
npx prisma --versionDiferencias notables en la API entre versiones:
relationJoinspuede cargar relaciones mediante JOIN en lugar de consultas separadas, pero puede causar explosión de filas en relaciones 1:N grandes oincludeprofundo — compara ambos enfoques- Se agregaron el modificador de campo
omity la API de Extensiones de Clienteprisma.$extends- Instalaciones más recientes: el paquete puede llamarse
prismaen lugar de@prisma/client;PrismaClientpuede requerir un adaptador de driver (ej.@prisma/adapter-pg);datasource.urlpuede estar enprisma.config.tsen lugar deschema.prisma- Los comandos CLI (
migrate dev,migrate deploy,generate) no cambian entre versiones
Cuándo activarlo
- Diseñar o modificar modelos y relaciones del esquema de Prisma
- Escribir consultas, transacciones o lógica de paginación
- Usar
updateMany,deleteManyo cualquier operación masiva - Ejecutar o planificar migraciones de base de datos
- Desplegar en entornos serverless (Vercel, Lambda, Cloudflare Workers)
- Implementar soft delete o filtrado de filas multiinquilino
Conceptos Centrales
Estrategia de ID
| Estrategia | Cuándo usarla | Cuándo evitarla |
|---|---|---|
@default(cuid()) | Opción por defecto: segura para URL, ordenable, sin colisiones | IDs secuenciales necesarios para sistemas externos |
@default(uuid()) | Se requiere interoperabilidad con sistemas que no son Prisma | Tablas de alta escritura (UUIDs aleatorios fragmentan índices B-tree) |
@default(autoincrement()) | Tablas de unión internas, registros de auditoría | IDs públicos (expone el conteo de registros) |
Valores por defecto del esquema
model User {
id String @id @default(cuid())
email String @unique // @unique ya crea un índice — no se necesita @@index
name String
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([createdAt])
@@index([deletedAt, createdAt]) // compuesto para soft delete + consultas de orden
}
- Agrega
@@indexen cada clave foránea y columna usada enWHEREuORDER BY. - Declara
deletedAt DateTime?de antemano cuando el soft delete sea un requisito previsible — agregarlo después requiere una migración en una tabla activa. updatedAt @updatedAtse establece automáticamente por Prisma solo enupdateyupsert(consulta Anti-Patrones para la trampa de actualización masiva).
include vs select
include | select | |
|---|---|---|
| Devuelve | Todos los campos escalares + relaciones especificadas | Solo los campos especificados |
| Úsalo cuando | Necesitas la mayoría de los campos más una relación | Rutas críticas, tablas grandes, evitando sobrecarga |
| Rendimiento | Puede sobrecargar en tablas anchas | Payload mínimo, más rápido en conjuntos de datos grandes |
| Nota de Prisma 5 | Usa JOIN por defecto (relationJoins) | Igual |
// include — todas las columnas + relación
const user = await prisma.user.findUnique({
where: { id },
include: { posts: { select: { id: true, title: true } } },
});
// select — lista explícita permitida
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, email: true, name: true },
});
Nunca devuelvas entidades Prisma crudas desde respuestas de API — mapea a DTOs de respuesta para controlar los campos expuestos:
// MAL: filtra passwordHash, deletedAt, campos internos
return await prisma.user.findUniqueOrThrow({ where: { id } });
// BIEN: mapeo explícito a DTO
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
return { id: user.id, name: user.name, email: user.email };
Selección de la forma de transacción
| Situación | Usar |
|---|---|
| Operaciones independientes, sin interdependencia | Forma de arreglo |
| Un paso posterior depende del resultado anterior | Forma interactiva |
| Llamadas externas (correo electrónico, HTTP) involucradas | Fuera de la transacción por completo |
// Forma de arreglo — agrupada en un solo viaje de ida y vuelta
const [user, post] = await prisma.$transaction([
prisma.user.update({ where: { id }, data: { name } }),
prisma.post.create({ data: { title, authorId: id } }),
]);
// Forma interactiva — usa solo el cliente tx, nunca el cliente prisma externo
const post = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({ where: { id } });
if (user.role !== 'ADMIN') throw new Error('Prohibido');
return tx.post.create({ data: { title, authorId: user.id } });
});
Singleton de PrismaClient
Cada instancia de PrismaClient abre su propio pool de conexiones. Crea una sola instancia.
// lib/prisma.ts
// Opción A — inicialización basada en adaptador (requerida por instalaciones más recientes de Prisma)
import { PrismaClient } from '@prisma/client'; // o la ruta del cliente generado para tu configuración
import { PrismaPg } from '@prisma/adapter-pg';
function createPrismaClient() {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
});
return new PrismaClient({
adapter,
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
});
}
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
// Opción B — inicialización directa (instalaciones antiguas, sin adaptador necesario)
// import { PrismaClient } from '@prisma/client';
// export const prisma = globalForPrisma.prisma ?? new PrismaClient({ ... });
Usa la Opción A si tu instalación de Prisma requiere un argumento adapter en el constructor de PrismaClient.
Usa la Opción B si new PrismaClient() funciona sin argumentos. Deja que el compilador te indique cuál es la correcta.
El patrón globalThis evita instancias duplicadas durante la recarga en caliente (Next.js, nodemon, ts-node-dev).
Problema N+1
Cargar relaciones dentro de un bucle genera una consulta por fila.
// MAL: N+1 — una consulta extra por usuario
const users = await prisma.user.findMany();
for (const user of users) {
const posts = await prisma.post.findMany({ where: { authorId: user.id } });
}
// BIEN: consulta única
const users = await prisma.user.findMany({ include: { posts: true } });
Con relationJoins de Prisma 5+, la forma include usa un solo JOIN. En conjuntos 1:N grandes, esto puede aumentar el tamaño del conjunto de resultados — compara ambos enfoques si la relación puede devolver muchas filas por padre.
Ejemplos de Código
Paginación por Cursor (preferida para feeds y conjuntos de datos grandes)
async function getPosts(cursor?: string, limit = 20) {
const items = await prisma.post.findMany({
where: { published: true },
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' }, // orden secundario evita paginación inestable en marcas de tiempo duplicadas
],
take: limit + 1,
...(cursor && { cursor: { id: cursor }, skip: 1 }),
});
const hasNextPage = items.length > limit;
if (hasNextPage) items.pop();
return { items, nextCursor: hasNextPage ? items[items.length - 1].id : null };
}
Obtén limit + 1 y saca el último — forma canónica de detectar hasNextPage sin una consulta de conteo adicional. Siempre incluye un campo único (ej. id) como orderBy secundario para evitar paginación inestable cuando varias filas comparten la misma marca de tiempo. Usa paginación por desplazamiento solo cuando los usuarios necesiten saltar a páginas arbitrarias (tablas de administración).
Soft Delete
// Filtra siempre explícitamente — no confíes en middleware (oculta el comportamiento, difícil de depurar)
const activeUsers = await prisma.user.findMany({ where: { deletedAt: null } });
await prisma.user.update({ where: { id }, data: { deletedAt: new Date() } });
await prisma.user.update({ where: { id }, data: { deletedAt: null } }); // restaurar
Manejo de Errores
import { Prisma } from '@prisma/client'; // o la ruta del cliente generado para tu configuración
try {
await prisma.user.create({ data: { email } });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') throw new ConflictError('El correo electrónico ya existe');
if (e.code === 'P2025') throw new NotFoundError('Registro no encontrado');
if (e.code === 'P2003') throw new BadRequestError('El registro referenciado no existe');
}
throw e;
}
Códigos comunes: P2002 violación de unicidad · P2025 no encontrado · P2003 violación de clave foránea.
Captura en el límite del servicio y traduce a errores de dominio. Nunca expongas mensajes crudos de Prisma a los consumidores de la API.
Pool de Conexiones — Serverless
Incrusta los parámetros de conexión directamente en DATABASE_URL — la concatenación de cadenas falla si la URL ya tiene parámetros de consulta (ej. ?schema=public):
# .env — preferido: incrusta parámetros en la URL
DATABASE_URL="postgresql://usuario:contraseña@host/base_de_datos?connection_limit=1&pool_timeout=20"
# Con un pooler externo (PgBouncer, pooler de Supabase)
DATABASE_URL="postgresql://usuario:contraseña@host/base_de_datos?pgbouncer=true&connection_limit=1"
// Vercel, AWS Lambda y runtimes serverless similares:
// limita el pool a 1 por instancia; connection_limit y pool_timeout controlados a través de DATABASE_URL
// Configuración basada en adaptador (si tu instalación de Prisma requiere un adaptador):
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
// Configuración directa (si tu instalación de Prisma no requiere un adaptador):
// const prisma = new PrismaClient();
Anti-Patrones
updateMany devuelve un conteo, no los registros
// MAL: el resultado es { count: 2 } — users[0] es undefined
const users = await prisma.user.updateMany({ where: { role: 'GUEST' }, data: { role: 'USER' } });
// BIEN: captura los IDs primero, luego actualiza, luego obtén solo las filas afectadas
const targets = await prisma.user.findMany({
where: { role: 'GUEST' },
select: { id: true },
});
const ids = targets.map((u) => u.id);
await prisma.user.updateMany({ where: { id: { in: ids } }, data: { role: 'USER' } });
const updated = await prisma.user.findMany({ where: { id: { in: ids } } });
Lo mismo aplica para deleteMany — devuelve { count: n }, nunca las filas eliminadas.
La forma interactiva de $transaction agota el tiempo de espera después de 5 segundos
// MAL: llamada externa dentro de la transacción supera el valor predeterminado de 5 s → "Transaction already closed"
await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({ where: { id } });
await sendWelcomeEmail(user.email); // llamada externa
await tx.user.update({ where: { id }, data: { emailSent: true } });
});
// BIEN: llamadas externas fuera de la transacción
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
await sendWelcomeEmail(user.email);
await prisma.user.update({ where: { id }, data: { emailSent: true } });
// Solo aumenta el tiempo de espera cuando el procesamiento masivo realmente lo necesita
await prisma.$transaction(async (tx) => { ... }, { timeout: 30_000 });
migrate dev puede reiniciar la base de datos
migrate dev detecta desviaciones del esquema y puede solicitar reiniciar la base de datos, eliminando todos los datos.
// NUNCA en desarrollo compartido, staging o producción
npx prisma migrate dev --name add_column
// Seguro en todas partes excepto en desarrollo local individual
npx prisma migrate deploy
// Verifica desviaciones sin aplicar
npx prisma migrate diff \
--from-migrations ./prisma/migrations \
--to-schema-datamodel ./prisma/schema.prisma \
--shadow-database-url "$SHADOW_DATABASE_URL"
Editar manualmente un archivo de migración rompe futuros despliegues
Prisma suma de verificación de cada archivo de migración. Editar después de aplicar causa P3006 checksum mismatch en cada entorno donde la original ya se ejecutó. Crea una nueva migración en su lugar.
Los cambios de esquema que rompen requieren migración en múltiples pasos
Agregar NOT NULL a una columna existente o renombrar una columna en una migración bloqueará la tabla o eliminará datos. Usa expandir-y-contraer:
# Paso 1: crea la migración localmente, luego despliega
npx prisma migrate dev --name add_new_column # solo local
npx prisma migrate deploy # staging / producción
// Paso 2: rellena los datos (ejecuta en un script o trabajo de migración, no en el shell)
await prisma.user.updateMany({ data: { newColumn: derivedValue } });
# Paso 3: crea la migración de la restricción NOT NULL localmente, luego despliega
npx prisma migrate dev --name make_new_column_required # solo local
npx prisma migrate deploy # staging / producción
@updatedAt no se activa en updateMany
@updatedAt se establece automáticamente solo en update y upsert. Las escrituras masivas lo dejan desactualizado.
// MAL: updatedAt permanece en su valor anterior
await prisma.post.updateMany({ where: { authorId }, data: { published: true } });
// BIEN
await prisma.post.updateMany({
where: { authorId },
data: { published: true, updatedAt: new Date() },
});
Soft delete + findUniqueOrThrow filtra registros eliminados
findUniqueOrThrow lanza P2025 solo cuando la fila no existe en la base de datos. Las filas con soft delete aún existen y se devuelven sin error.
findUniqueOrThrow requiere un campo de restricción única en where — agregar deletedAt: null junto a id rompe el tipo porque { id, deletedAt } no es una restricción única compuesta. Usa findFirstOrThrow en su lugar.
// MAL: devuelve un usuario con soft delete
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
// MAL: error de tipo de Prisma — { id, deletedAt } no es una restricción única
const user = await prisma.user.findUniqueOrThrow({ where: { id, deletedAt: null } });
// BIEN: findFirstOrThrow admite condiciones where arbitrarias
const user = await prisma.user.findFirstOrThrow({ where: { id, deletedAt: null } });
deleteMany sin where elimina cada fila
// MAL: limpia la tabla silenciosamente
await prisma.post.deleteMany();
// BIEN
await prisma.post.deleteMany({ where: { authorId: userId } });
Buenas Prácticas
| Regla | Razón |
|---|---|
migrate deploy en CI/CD, migrate dev solo localmente | migrate dev puede reiniciar la base de datos en caso de desviación |
| Mapea entidades a DTOs de respuesta | Evita filtrar campos internos |
Siempre especifica where en deleteMany y updateMany | deleteMany() sin filtro elimina todas las filas; updateMany() devuelve un conteo, no registros |
Maneja errores de Prisma con instanceof y códigos de error | Proporciona mensajes de error significativos y evita exponer detalles internos |
| Usa paginación por cursor para listas grandes | Más eficiente que la paginación por desplazamiento en conjuntos de datos grandes y evita la inestabilidad |
| No anides llamadas externas dentro de transacciones interactivas | Las transacciones tienen un tiempo de espera predeterminado de 5 segundos y las llamadas externas pueden excederlo |
| Siempre incrusta los parámetros de conexión en la URL de la base de datos | La concatenación de cadenas puede fallar si la URL ya tiene parámetros de consulta |
| Crea una nueva migración para cambios de esquema | Editar archivos de migración existentes causa errores de suma de verificación |
Usa findFirstOrThrow en lugar de findUniqueOrThrow con filtros de soft delete | findUniqueOrThrow requiere una restricción única y no admite filtros adicionales |
Establece manualmente updatedAt en operaciones masivas | @updatedAt no se activa en updateMany |
Usa el patrón globalThis para el singleton de PrismaClient | Evita instancias duplicadas durante la recarga en caliente |
| No devuelvas entidades Prisma de API | Siempre mapea a DTOs para controlar la exposición de datos y evitar fugas de información |
Agrega @@index en claves foráneas y columnas usadas en filtros y ordenamientos | Mejora el rendimiento de las consultas |
Declara deletedAt DateTime? al inicio del proyecto | Agregarlo después requiere una migración en tablas activas |
| Usa la forma de arreglo de transacciones para operaciones independientes | Más eficiente que la forma interactiva y reduce el tiempo de espera |
| Limita el pool de conexiones a 1 en entornos serverless | Evita el agotamiento de conexiones y mejora la escalabilidad |
Compara include vs select en relaciones grandes 1:N | relationJoins puede causar explosión de filas; elige según el caso de uso |
Siempre especifica un campo único como orderBy secundario en paginación por cursor | Evita paginación inestable cuando varias filas comparten la misma marca de tiempo |
| No uses middleware para filtrar soft deletes | Oculta el comportamiento y dificulta la depuración; filtra explícitamente en cada consulta |