Prisma ORM: gestionar bases de datos en Node.js de forma moderna

D
DanisCh
(Actualizado: ) • 12 min de lectura
Prisma ORM: gestionar bases de datos en Node.js de forma moderna
Conceptos de Base de Datos ORM JavaScript

Prisma es el ORM más popular del ecosistema Node.js y TypeScript en 2026. A diferencia de ORMs más tradicionales como Sequelize o TypeORM, Prisma adopta un enfoque centrado en el esquema: defines tus modelos en un archivo schema.prisma, y Prisma genera automáticamente un cliente con autocompletado completo y tipado estricto. El resultado es que trabajar con la base de datos desde TypeScript se siente casi tan seguro como trabajar con datos locales.

En esta guía aprenderás a instalar Prisma, definir tu esquema, hacer migraciones y escribir consultas reales para los casos de uso más habituales.

Instalación y configuración inicial

# Crear un proyecto Node.js con TypeScript
mkdir mi-proyecto && cd mi-proyecto
npm init -y
npm install typescript ts-node @types/node --save-dev
npx tsc --init

# Instalar Prisma
npm install prisma --save-dev
npm install @prisma/client

# Inicializar Prisma (crea el archivo schema.prisma y el .env)
npx prisma init --datasource-provider postgresql
# Para MySQL:   npx prisma init --datasource-provider mysql
# Para SQLite:  npx prisma init --datasource-provider sqlite

Después de ejecutar prisma init, se crea la siguiente estructura:

mi-proyecto/
├── prisma/
│   └── schema.prisma     ← definición del esquema y modelos
├── .env                  ← cadena de conexión a la base de datos
├── package.json
└── tsconfig.json

Configura la cadena de conexión en el archivo .env:

# .env
DATABASE_URL="postgresql://usuario:contraseña@localhost:5432/mi_base_datos"

# Para MySQL:
# DATABASE_URL="mysql://usuario:contraseña@localhost:3306/mi_base_datos"

# Para SQLite (sin servidor, ideal para desarrollo):
# DATABASE_URL="file:./dev.db"

Definir el esquema

El archivo schema.prisma es el centro de Prisma. Aquí defines los modelos (tablas), sus campos (columnas), tipos, relaciones y restricciones. La sintaxis es clara y mucho más concisa que escribir SQL a mano.

// 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
  bio         String?   // ? = campo opcional (puede ser null)
  activo      Boolean   @default(true)
  creado_en   DateTime  @default(now())
  actualizado DateTime  @updatedAt   // se actualiza automáticamente

  // Relaciones
  publicaciones  Publicacion[]
  perfil         Perfil?           // relación uno a uno (opcional)

  @@map("usuarios")   // nombre de la tabla en la BD (si difiere del modelo)
}

model Perfil {
  id         Int      @id @default(autoincrement())
  avatar     String?
  sitio_web  String?
  ubicacion  String?

  // Relación uno a uno con Usuario
  usuario    Usuario  @relation(fields: [usuario_id], references: [id], onDelete: Cascade)
  usuario_id Int      @unique

  @@map("perfiles")
}

model Publicacion {
  id          Int        @id @default(autoincrement())
  titulo      String
  contenido   String
  publicado   Boolean    @default(false)
  creado_en   DateTime   @default(now())
  autor       Usuario    @relation(fields: [autor_id], references: [id])
  autor_id    Int
  categoria   Categoria  @relation(fields: [categoria_id], references: [id])
  categoria_id Int
  etiquetas   Etiqueta[]
  comentarios Comentario[]

  // Índice compuesto para búsquedas frecuentes
  @@index([autor_id, publicado])
  @@index([categoria_id])
  @@map("publicaciones")
}

model Categoria {
  id            Int           @id @default(autoincrement())
  nombre        String        @unique
  publicaciones Publicacion[]

  @@map("categorias")
}

model Etiqueta {
  id            Int           @id @default(autoincrement())
  nombre        String        @unique
  publicaciones Publicacion[] // relación muchos a muchos (Prisma gestiona la tabla intermedia)

  @@map("etiquetas")
}

model Comentario {
  id            Int         @id @default(autoincrement())
  texto         String
  creado_en     DateTime    @default(now())
  publicacion   Publicacion @relation(fields: [publicacion_id], references: [id], onDelete: Cascade)
  publicacion_id Int

  @@map("comentarios")
}

Migraciones: llevar el esquema a la base de datos

# Crear y aplicar una migración (en desarrollo)
# Prisma genera el SQL necesario a partir del schema.prisma
npx prisma migrate dev --name crear_esquema_inicial

# Prisma crea el archivo de migración en:
# prisma/migrations/20260615120000_crear_esquema_inicial/migration.sql
# y lo aplica a la base de datos inmediatamente

# Ver el SQL que generó Prisma
cat prisma/migrations/20260615120000_crear_esquema_inicial/migration.sql
# Cuando modificas el schema.prisma y quieres aplicar los cambios:
# 1. Haz los cambios en schema.prisma (añadir campo, modelo, índice...)
# 2. Ejecutar la migración
npx prisma migrate dev --name anadir_campo_bio_usuario

# En producción (no modifica el schema, solo aplica migraciones pendientes):
npx prisma migrate deploy

# Ver el estado de las migraciones
npx prisma migrate status

# Si el esquema y la BD están desincronizados (solo para desarrollo):
npx prisma migrate reset   # CUIDADO: borra y recrea toda la BD
# Generar el cliente Prisma después de cambios en el schema
# (migrate dev lo hace automáticamente, pero a veces hay que hacerlo a mano)
npx prisma generate

# Abrir Prisma Studio: interfaz visual para explorar y editar los datos
npx prisma studio

Inicializar el cliente Prisma

// src/db.ts — instancia singleton del cliente Prisma

import { PrismaClient } from '@prisma/client';

// En producción es importante usar un singleton para no abrir demasiadas conexiones
const globalForPrisma = global as unknown as { prisma: PrismaClient };

export const prisma =
  globalForPrisma.prisma ||
  new PrismaClient({
    log: ['query', 'error', 'warn'],   // mostrar las queries en desarrollo
  });

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma;
}

Operaciones CRUD básicas

import { prisma } from './db';

// ─── CREATE ───────────────────────────────────────────────────────────────────

// Crear un usuario
const nuevoUsuario = await prisma.usuario.create({
  data: {
    email: 'ana@mail.com',
    nombre: 'Ana García',
    bio: 'Desarrolladora full-stack apasionada por TypeScript',
  },
});
console.log(nuevoUsuario.id);   // número autoincremental asignado por la BD

// Crear con relación anidada: usuario + perfil en una sola operación
const usuarioConPerfil = await prisma.usuario.create({
  data: {
    email: 'carlos@mail.com',
    nombre: 'Carlos López',
    perfil: {
      create: {
        avatar:   'https://cdn.ejemplo.com/avatars/carlos.jpg',
        ubicacion: 'Madrid',
      },
    },
  },
  include: { perfil: true },   // incluir el perfil en la respuesta
});

// Crear muchos registros de una vez (más eficiente que varios create)
const { count } = await prisma.categoria.createMany({
  data: [
    { nombre: 'Tecnología' },
    { nombre: 'Diseño' },
    { nombre: 'Negocios' },
    { nombre: 'Ciencia' },
  ],
  skipDuplicates: true,   // ignorar si ya existe (no falla)
});
console.log(`Creadas ${count} categorías`);
// ─── READ ─────────────────────────────────────────────────────────────────────

// Buscar por clave primaria (findUnique requiere campo único)
const usuario = await prisma.usuario.findUnique({
  where: { id: 1 },
});

// Buscar por campo único
const porEmail = await prisma.usuario.findUnique({
  where: { email: 'ana@mail.com' },
});

// findFirst: primera coincidencia (no requiere campo único)
const primerActivo = await prisma.usuario.findFirst({
  where: { activo: true },
  orderBy: { creado_en: 'desc' },
});

// findMany: todos los que coincidan con los filtros
const usuariosActivos = await prisma.usuario.findMany({
  where: { activo: true },
  orderBy: { nombre: 'asc' },
  take: 10,    // equivale a LIMIT
  skip: 20,    // equivale a OFFSET (para paginación)
});

// Seleccionar solo los campos necesarios (evitar over-fetching)
const nombres = await prisma.usuario.findMany({
  select: {
    id:     true,
    nombre: true,
    email:  true,
    // activo, bio, etc. no se incluyen → más eficiente
  },
});

// Incluir relaciones
const usuarioConPublicaciones = await prisma.usuario.findUnique({
  where: { id: 1 },
  include: {
    publicaciones: {
      where:   { publicado: true },
      orderBy: { creado_en: 'desc' },
      take:    5,
      include: {
        categoria: true,
        etiquetas: true,
      },
    },
    perfil: true,
  },
});

 

// ─── UPDATE ───────────────────────────────────────────────────────────────────

// Actualizar un registro por clave única
const actualizado = await prisma.usuario.update({
  where: { id: 1 },
  data:  { bio: 'Bio actualizada', activo: false },
});

// Actualizar muchos registros a la vez
const { count: desactivados } = await prisma.usuario.updateMany({
  where: {
    activo:    true,
    creado_en: { lt: new Date('2023-01-01') }   // creados antes de 2023
  },
  data: { activo: false },
});
console.log(`${desactivados} usuarios desactivados`);

// Upsert: crear si no existe, actualizar si existe
const upserted = await prisma.usuario.upsert({
  where:  { email: 'nuevo@mail.com' },
  update: { nombre: 'Nombre actualizado' },
  create: {
    email:  'nuevo@mail.com',
    nombre: 'Usuario Nuevo',
  },
});

 

// ─── DELETE ───────────────────────────────────────────────────────────────────

// Eliminar por clave única
await prisma.usuario.delete({
  where: { id: 1 },
});

// Eliminar muchos registros
const { count: eliminados } = await prisma.publicacion.deleteMany({
  where: {
    publicado: false,
    creado_en: { lt: new Date('2024-01-01') }   // borradores antiguos
  },
});
console.log(`${eliminados} borradores eliminados`);

 

Filtros avanzados

import { prisma } from './db';

// Operadores de comparación
const publicaciones = await prisma.publicacion.findMany({
  where: {
    // Igualdad (por defecto)
    publicado:    true,

    // Comparaciones numéricas/de fecha
    creado_en:    { gte: new Date('2026-01-01') },   // >= (greater than or equal)

    // En una lista de valores
    categoria_id: { in: [1, 2, 3] },

    // Búsqueda de texto (LIKE)
    titulo:       { contains: 'TypeScript', mode: 'insensitive' },   // insensible a mayúsculas

    // Empieza por
    titulo:       { startsWith: 'Cómo' },

    // NOT: negación
    autor_id:     { not: 5 },
  }
});

// Operadores lógicos AND, OR, NOT
const resultados = await prisma.publicacion.findMany({
  where: {
    AND: [
      { publicado: true },
      { creado_en: { gte: new Date('2026-01-01') } }
    ]
  }
});

const resultadosOR = await prisma.publicacion.findMany({
  where: {
    OR: [
      { titulo:    { contains: 'React' } },
      { contenido: { contains: 'React' } }
    ]
  }
});

// Filtrar por relaciones
const publicacionesDeAna = await prisma.publicacion.findMany({
  where: {
    autor: {
      email: 'ana@mail.com'   // filtrar por campo del modelo relacionado
    }
  }
});

// some / every / none: filtros en relaciones "a muchos"
const usuariosConPublicaciones = await prisma.usuario.findMany({
  where: {
    publicaciones: {
      some: {
        publicado: true   // tiene AL MENOS UNA publicación publicada
      }
    }
  }
});

const usuariosSinPublicaciones = await prisma.usuario.findMany({
  where: {
    publicaciones: {
      none: {}   // no tiene ninguna publicación
    }
  }
});

 

Agregaciones y consultas analíticas

// Contar
const totalUsuarios = await prisma.usuario.count();
const totalActivos  = await prisma.usuario.count({ where: { activo: true } });

// Agregaciones numéricas
const estadisticas = await prisma.publicacion.aggregate({
  _count:  { id: true },          // número de publicaciones
  _avg:    { categoria_id: true }, // no muy útil, solo para ilustrar
  where:   { publicado: true }
});

// Group by: agrupar y agregar
const publicacionesPorCategoria = await prisma.publicacion.groupBy({
  by:     ['categoria_id'],
  _count: { id: true },
  _min:   { creado_en: true },
  _max:   { creado_en: true },
  where:  { publicado: true },
  orderBy: { _count: { id: 'desc' } },   // ordenar por número de publicaciones
});

console.log(publicacionesPorCategoria);
// [
//   { categoria_id: 1, _count: { id: 42 }, _min: { creado_en: ... }, _max: { ... } },
//   { categoria_id: 3, _count: { id: 28 }, ... },
//   ...
// ]

 

Transacciones

// Transacción secuencial: todas las operaciones en la misma transacción
// Si alguna falla, todas se revierten automáticamente
const [nuevaPublicacion, usuarioActualizado] = await prisma.$transaction([
  prisma.publicacion.create({
    data: {
      titulo:      'Mi nueva publicación',
      contenido:   'Contenido aquí...',
      autor_id:    1,
      categoria_id: 2,
    }
  }),
  prisma.usuario.update({
    where: { id: 1 },
    data:  { actualizado: new Date() }
  })
]);

// Transacción interactiva: más flexible, permite lógica condicional
const resultado = await prisma.$transaction(async (tx) => {
  // Verificar que el usuario existe
  const usuario = await tx.usuario.findUnique({ where: { id: 1 } });
  if (!usuario) throw new Error('Usuario no encontrado');

  // Contar sus publicaciones actuales
  const numPublicaciones = await tx.publicacion.count({
    where: { autor_id: 1 }
  });

  if (numPublicaciones >= 100) {
    throw new Error('Has alcanzado el límite de publicaciones');
  }

  // Crear la publicación
  const publicacion = await tx.publicacion.create({
    data: {
      titulo:      'Publicación con validación',
      contenido:   '...',
      autor_id:    1,
      categoria_id: 1,
    }
  });

  return { publicacion, total: numPublicaciones + 1 };
  // Si cualquier operación falla, el tx hace rollback automático
});

 

SQL raw: cuando Prisma no llega

// Para consultas muy específicas que el API de Prisma no puede expresar,
// puedes usar SQL raw directamente

// queryRaw: ejecutar SQL y recibir los resultados tipados
const resultado = await prisma.$queryRaw<{ nombre: string; total: number }[]>`
  SELECT u.nombre, COUNT(p.id)::int AS total
  FROM usuarios u
  LEFT JOIN publicaciones p ON p.autor_id = u.id
  WHERE u.activo = true
  GROUP BY u.id, u.nombre
  ORDER BY total DESC
  LIMIT 10
`;

resultado.forEach(r => console.log(`${r.nombre}: ${r.total} publicaciones`));

// executeRaw: ejecutar SQL sin retorno de datos (INSERT, UPDATE, DELETE)
const filas = await prisma.$executeRaw`
  UPDATE publicaciones
  SET publicado = true
  WHERE creado_en < ${new Date('2026-01-01')}
    AND publicado = false
`;
console.log(`${filas} publicaciones marcadas como publicadas`);

// IMPORTANTE: usar template literals con ${}
// para que Prisma parametrice automáticamente los valores y prevenga SQL injection
// NUNCA: prisma.$queryRaw(`SELECT * FROM usuarios WHERE id = ${id}`)  ← vulnerable
// SIEMPRE: prisma.$queryRaw`SELECT * FROM usuarios WHERE id = ${id}`  ← seguro

 

Prisma con Express: ejemplo completo de API REST

// src/index.ts

import express from 'express';
import { prisma } from './db';

const app = express();
app.use(express.json());

// GET /usuarios — listar con paginación
app.get('/usuarios', async (req, res) => {
  const pagina = Number(req.query.pagina) || 1;
  const limite = Number(req.query.limite) || 10;
  const skip   = (pagina - 1) * limite;

  const [usuarios, total] = await prisma.$transaction([
    prisma.usuario.findMany({
      where:   { activo: true },
      select:  { id: true, nombre: true, email: true, creado_en: true },
      orderBy: { nombre: 'asc' },
      skip,
      take: limite,
    }),
    prisma.usuario.count({ where: { activo: true } }),
  ]);

  res.json({
    datos:       usuarios,
    total,
    pagina,
    total_paginas: Math.ceil(total / limite),
  });
});

// GET /usuarios/:id — obtener uno con sus publicaciones
app.get('/usuarios/:id', async (req, res) => {
  const usuario = await prisma.usuario.findUnique({
    where:   { id: Number(req.params.id) },
    include: {
      perfil: true,
      publicaciones: {
        where:   { publicado: true },
        orderBy: { creado_en: 'desc' },
        take:    5,
        select:  { id: true, titulo: true, creado_en: true },
      },
    },
  });

  if (!usuario) return res.status(404).json({ error: 'Usuario no encontrado' });
  res.json(usuario);
});

// POST /usuarios — crear usuario
app.post('/usuarios', async (req, res) => {
  const { email, nombre, bio } = req.body;

  try {
    const usuario = await prisma.usuario.create({
      data: { email, nombre, bio },
    });
    res.status(201).json(usuario);
  } catch (error: any) {
    if (error.code === 'P2002') {   // código Prisma para unique constraint violation
      return res.status(409).json({ error: 'El email ya está registrado' });
    }
    res.status(500).json({ error: 'Error interno del servidor' });
  }
});

// PATCH /usuarios/:id — actualizar parcialmente
app.patch('/usuarios/:id', async (req, res) => {
  try {
    const usuario = await prisma.usuario.update({
      where: { id: Number(req.params.id) },
      data:  req.body,
      select: { id: true, nombre: true, email: true, bio: true },
    });
    res.json(usuario);
  } catch (error: any) {
    if (error.code === 'P2025') {   // registro no encontrado
      return res.status(404).json({ error: 'Usuario no encontrado' });
    }
    res.status(500).json({ error: 'Error interno del servidor' });
  }
});

// DELETE /usuarios/:id — desactivar (soft delete)
app.delete('/usuarios/:id', async (req, res) => {
  try {
    await prisma.usuario.update({
      where: { id: Number(req.params.id) },
      data:  { activo: false },
    });
    res.status(204).send();
  } catch {
    res.status(404).json({ error: 'Usuario no encontrado' });
  }
});

app.listen(3000, () => console.log('API en http://localhost:3000'));

Códigos de error más comunes de Prisma

CódigoSignificadoCuándo ocurre
P2002Unique constraint violationIntentar insertar un valor duplicado en un campo @unique
P2025Record not foundupdate, delete o findUniqueOrThrow sobre un registro que no existe
P2003Foreign key constraint failedInsertar o actualizar con una clave foránea que no existe en la tabla referenciada
P2014Relation violationRomper una relación requerida
P1001Connection refusedNo se puede conectar al servidor de base de datos

Resumen

  • Prisma gira alrededor del archivo schema.prisma donde defines tus modelos, tipos, relaciones e índices. El cliente TypeScript se genera automáticamente con tipado completo.
  • Las migraciones se gestionan con prisma migrate dev (desarrollo) y prisma migrate deploy (producción). Prisma genera el SQL por ti y mantiene un historial versionado.
  • El cliente ofrece métodos para todas las operaciones CRUD: create, createMany, findUnique, findFirst, findMany, update, updateMany, upsert, delete, deleteMany.
  • Los filtros cubren comparaciones (gte, lt, in), texto (contains, startsWith), lógica (AND, OR, NOT) y relaciones (some, every, none).
  • select permite pedir solo los campos necesarios; include permite cargar relaciones anidadas en la misma consulta.
  • Las transacciones se hacen con $transaction([]) para operaciones paralelas o $transaction(async tx => ...) para lógica condicional con rollback automático.
  • Para consultas que el API no puede expresar, $queryRaw y $executeRaw permiten SQL directo con parametrización automática para prevenir inyección SQL.
  • Los errores más habituales tienen códigos específicos (P2002 para duplicados, P2025 para registros no encontrados) que puedes capturar y tratar de forma diferenciada.
Etiquetas: Node.js CRUD Node.js

¿Te ha gustado esta entrada?

Compártela con tus compañeros para que también sigan aprendiendo.

Comunidad y Comentarios

0 COMENTARIOS

No hay comentarios todavía. Sé el primero en compartir tu opinión.

Escribe tu opinión
Respondiendo a