Patrones de Prisma
Patrones de producción y trampas no obvias para Prisma ORM en backends TypeScript.
Verifica tu versión antes de aplicar los patrones. La superficie de API de Prisma ha evolucionado a través de versiones importantes:
npx prisma --versionDiferencias notables de 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- El modificador de campo
omity la API de Extensiones de Clienteprisma.$extendsse agregaron- Instalaciones más recientes: el paquete puede llamarse
prismaen lugar de@prisma/client;PrismaClientpuede requerir un adaptador de driver (por ejemplo,@prisma/adapter-pg);datasource.urlpuede estar enprisma.config.tsen lugar deschema.prisma- Los comandos CLI (
migrate dev,migrate deploy,generate) no han cambiado entre versiones
Cuándo activarlo
- Diseñar o modificar modelos y relaciones del esquema 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 principales
Estrategia de ID
| Estrategia | ¿Cuándo usarla? | ¿Cuándo evitarla? |
|---|---|---|
@default(cuid()) | Opción predeterminada: segura para URL, ordenable, sin colisiones | Se necesitan IDs secuenciales para sistemas externos |
@default(uuid()) | Se requiere interoperabilidad con sistemas que no son Prisma | Tablas de alta escritura (los UUIDs aleatorios fragmentan los índices B-tree) |
@default(autoincrement()) | Tablas internas de unión, registros de auditoría | IDs visibles públicamente (expone el conteo de registros) |
Valores predeterminados 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 ordenación
}
- Añade
@@indexen cada clave foránea y columna usada enWHEREoORDER BY. - Declara
deletedAt DateTime?por adelantado cuando el soft delete sea un requisito previsible — agregarlo después requiere una migración en una tabla en vivo. updatedAt @updatedAtlo establece Prisma automáticamente 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, evitar sobrecarga de datos |
| Rendimiento | Puede sobrecargar en tablas anchas | Carga útil mínima, más rápido en conjuntos de datos grandes |
| Nota de Prisma 5 | Usa JOIN por defecto (relationJoins) | Mismo |
// include — todas las columnas + relación
const user = await prisma.user.findUnique({
where: { id },
include: { posts: { select: { id: true, title: true } } },
});
// select — lista blanca explícita
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, email: true, name: true },
});
Nunca devuelvas entidades Prisma sin procesar 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 forma de transacción
| Situación | Usa |
|---|---|
| Operaciones independientes, sin interdependencia | Forma de arreglo |
| Un paso posterior depende del resultado anterior | Forma interactiva |
| Llamadas externas (email, HTTP) involucradas | Fuera de la transacción por completo |
// Forma de arreglo — agrupadas 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 necesidad de adaptador)
// 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 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: una sola consulta
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' }, // ordenamiento 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 elimina el último: forma canónica de detectar hasNextPage sin una consulta de conteo adicional. Incluye siempre un campo único (por ejemplo, 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
// Siempre filtra 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 email 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.
Atrapa en el límite del servicio y traduce a errores de dominio. Nunca expongas mensajes sin procesar 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 se rompe si la URL ya tiene parámetros de consulta (por ejemplo, ?schema=public):
# .env — preferido: incrusta parámetros en la URL
DATABASE_URL="postgresql://user:pass@host/db?connection_limit=1&pool_timeout=20"
# Con un pooler externo (PgBouncer, pooler de Supabase)
DATABASE_URL="postgresql://user:pass@host/db?pgbouncer=true&connection_limit=1"
// Vercel, AWS Lambda y tiempos de ejecución serverless similares:
// limita el pool a 1 por instancia; connection_limit y pool_timeout controlados mediante 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 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 y 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 expira después de 5 segundos
// MAL: llamada externa dentro de la transacción supera el valor predeterminado de 5s → "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 por lotes realmente lo necesite
await prisma.$transaction(async (tx) => { ... }, { timeout: 30_000 });
migrate dev puede reiniciar la base de datos
migrate dev detecta desviaciones en el esquema y puede solicitar reiniciar la BD, eliminando todos los datos.
# NUNCA en entornos compartidos de desarrollo, 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 despliegues futuros
Prisma suma de verificación cada archivo de migración. Editarlo después de aplicar causa P3006 checksum mismatch en cada entorno donde la migración original ya se ejecutó. Crea una nueva migración en su lugar.
Los cambios de esquema que rompen la compatibilidad requieren migración en varios pasos
Agregar NOT NULL a una columna existente o renombrar una columna en una sola 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 obsoleto.
// MAL: updatedAt se queda con 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 BD. 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 todas las filas
// MAL: limpia la tabla silenciosamente
await prisma.post.deleteMany();
// BIEN
await prisma.post.deleteMany({ where: { authorId: userId } });
Mejores prácticas
| Regla | Razón |
|---|---|
migrate deploy en CI/CD, migrate dev solo localmente | migrate dev puede reiniciar la BD en caso de desviación |
| Mapear entidades a DTOs de respuesta | Evita filtrar campos internos |
Usar findFirstOrThrow en lugar de findUniqueOrThrow con filtros adicionales | findUniqueOrThrow solo admite campos de restricción única en where |
Establecer updatedAt manualmente en updateMany y deleteMany | @updatedAt no se activa en operaciones masivas |
Mover llamadas externas fuera de $transaction | El valor predeterminado de 5s de la transacción provocará errores |
| Usar paginación por cursor para feeds y conjuntos de datos grandes | Más estable y escalable que la paginación por desplazamiento en datos agregados con frecuencia |
Usar updateMany+findMany en lugar de actualizaciones de una sola fila en bucles | Reduce drásticamente los viajes de ida y vuelta a la BD |
Incluir siempre un campo único como orderBy secundario en la paginación por cursor | Evita resultados de paginación inestables cuando varias filas tienen el mismo valor de ordenación |
| Mantener el pool de conexiones en 1 para serverless | Cada instancia serverless maneja una solicitud a la vez; un pool más grande desperdicia conexiones |