CRUD con ORM: SQLAlchemy, Prisma y Hibernate
Cómo ejecutar operaciones de creación, lectura, actualización y borrado con SQLAlchemy, Prisma y Hibernate: el mismo modelo de datos en Python, JavaScript y Java.
Visión General
Un ORM (Object-Relational Mapper) convierte las filas de una tabla en objetos del lenguaje, de modo que las operaciones CRUD se vuelven llamadas a métodos en lugar de SQL escrito a mano. Esta receta implementa el mismo modelo User/Post en tres ORMs — SQLAlchemy (Python), Prisma (JavaScript) y Hibernate/JPA (Java) — y luego muestra variantes para Django ORM, Spring Data y TypeORM.
El patrón de fondo es siempre el mismo: declaras un modelo, el framework traduce tus operaciones sobre objetos a SQL parametrizado, y las filas vuelven como objetos. Las diferencias están en cómo cada herramienta expresa relaciones, transacciones y carga anticipada.
Cuándo Usar
Usa un ORM cuando:
- Tu aplicación tiene varios tipos de entidades con relaciones (usuarios, posts, pedidos) y ya no quieres escribir a mano los mismos
INSERT/SELECT/UPDATE/DELETE. - Quieres consultas con seguridad de tipos y autocompletado en el IDE en lugar de strings de SQL que fallan en tiempo de ejecución.
- Los cambios de esquema deben vivir junto a los modelos: Alembic para SQLAlchemy, Prisma Migrate, o Flyway/Liquibase para Hibernate.
- La mayoría de tus consultas son CRUD con joins sencillos, que es lo habitual en una aplicación.
Evita el ORM y usa SQL directo para agregaciones pesadas, trabajos ETL o rutas críticas donde necesitas controlar la consulta exacta que se genera. Los ORMs producen SQL genérico, correcto para CRUD pero a menudo derrochador en analítica.
Solución
Python — SQLAlchemy
from sqlalchemy import create_engine, String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String, unique=True)
role: Mapped[str] = mapped_column(String, default="user")
engine = create_engine("postgresql+psycopg://user:pass@localhost/mydb")
with Session(engine) as session:
# Crear
user = User(email="alice@example.com", role="admin")
session.add(user)
session.commit()
# Leer
user = session.scalar(
select(User).where(User.email == "alice@example.com")
)
# Actualizar — la sesión detecta el atributo modificado
user.role = "superadmin"
session.commit()
# Borrar
session.delete(user)
session.commit()
Estilo SQLAlchemy 2.x: select() con session.scalar()/session.execute() reemplaza a la API antigua session.query(). El bloque with Session(...) garantiza que la conexión vuelva al pool aunque algo lance una excepción.
JavaScript — Prisma
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function crud() {
// Crear
const user = await prisma.user.create({
data: { email: 'alice@example.com', role: 'admin' }
});
// Leer
const found = await prisma.user.findUnique({
where: { email: 'alice@example.com' }
});
// Actualizar
const updated = await prisma.user.update({
where: { id: user.id },
data: { role: 'superadmin' }
});
// Borrar
await prisma.user.delete({ where: { id: user.id } });
}
Java — Hibernate / JPA
import jakarta.persistence.*;
import java.util.List;
@Entity
@Table(name = "users")
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(nullable = false, unique = true)
private String email;
private String role = "user";
// getters y setters omitidos
}
public class UserRepository {
private final EntityManager em;
public UserRepository(EntityManager em) { this.em = em; }
public void create(User user) {
em.getTransaction().begin();
em.persist(user);
em.getTransaction().commit();
}
public User findByEmail(String email) {
return em.createQuery("SELECT u FROM User u WHERE u.email = :email", User.class)
.setParameter("email", email)
.getSingleResult();
}
public void updateRole(Integer id, String role) {
em.getTransaction().begin();
User user = em.find(User.class, id);
user.setRole(role);
em.getTransaction().commit();
}
public void delete(Integer id) {
em.getTransaction().begin();
em.remove(em.find(User.class, id));
em.getTransaction().commit();
}
}
Explicación
Las tres herramientas comparten el mismo pipeline: entran tus objetos, sale SQL, y las filas regresan como objetos:
SQLAlchemy mapea clases a tablas mediante DeclarativeBase y columnas Mapped. Una Session actúa como unidad de trabajo y como caché: registra qué objetos cambiaron y envuelve esos cambios en una transacción cuando llamas a commit(). Prisma toma otro camino: declaras los modelos una vez en schema.prisma y genera un cliente completamente tipado, así que un campo mal escrito falla en el editor antes de llegar a la base de datos. Hibernate usa anotaciones JPA (@Entity, @Id, @Column); el EntityManager es el contexto de persistencia que detecta entidades modificadas y las sincroniza al hacer commit.
Nada de esto elimina el SQL, solo lo desplaza. Cuando una consulta se vuelve lenta, el SQL que genera el ORM es el primer lugar donde mirar, y verificar la consulta generada con EXPLAIN funciona mejor que adivinar.
Variantes
| Tecnología | Enfoque | Notas |
|---|---|---|
| Python | Django ORM | Todo incluido, fuertemente acoplado a Django |
| JavaScript | TypeORM | ORM basado en decoradores con fuerte soporte de TypeScript |
| Java | Spring Data JPA | Abstracción de repositorio sobre Hibernate |
| MongoDB | Mongoose | Almacén de documentos, no relacional — ver la receta CRUD con Mongoose |
Ejemplos Avanzados
SQLAlchemy con Relaciones y Carga Anticipada
from sqlalchemy import create_engine, String, ForeignKey, select, update
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session, relationship, selectinload
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String, unique=True)
role: Mapped[str] = mapped_column(String, default="user")
posts: Mapped[list["Post"]] = relationship(back_populates="author", cascade="all, delete-orphan")
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
author: Mapped[User] = relationship(back_populates="posts")
engine = create_engine("postgresql+psycopg://user:pass@localhost/mydb")
with Session(engine) as session:
# Carga anticipada con selectinload — evita N+1
stmt = (
select(User)
.options(selectinload(User.posts))
.where(User.role == "admin")
)
admins_with_posts = session.execute(stmt).scalars().all()
# Inserción masiva
session.add_all([
User(email=f"user{i}@example.com", role="user")
for i in range(100)
])
session.commit()
# Actualización masiva con Core
session.execute(
update(User)
.where(User.role == "user")
.values(role="member")
)
session.commit()
Esquema de Prisma con Relaciones
// schema.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
role String @default("user")
posts Post[]
@@index([role])
}
model Post {
id Int @id @default(autoincrement())
title String
authorId Int
author User @relation(fields: [authorId], references: [id])
@@index([authorId])
}
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
// Crear con registros anidados
const user = await prisma.user.create({
data: {
email: 'alice@example.com',
role: 'admin',
posts: {
create: [
{ title: 'First Post' },
{ title: 'Second Post' },
],
},
},
include: { posts: true }, // Carga anticipada
});
// Transacción con múltiples operaciones
const [newUser, updatedPost] = await prisma.$transaction([
prisma.user.create({ data: { email: 'bob@example.com' } }),
prisma.post.update({ where: { id: 1 }, data: { title: 'Updated' } }),
]);
// Upsert (crear o actualizar)
const result = await prisma.user.upsert({
where: { email: 'carol@example.com' },
update: { role: 'admin' },
create: { email: 'carol@example.com', role: 'admin' },
});
// SQL directo para consultas complejas
const topUsers = await prisma.$queryRaw`
SELECT u.email, COUNT(p.id) AS post_count
FROM users u
LEFT JOIN posts p ON p.author_id = u.id
GROUP BY u.email
ORDER BY post_count DESC
LIMIT 10
`;
Django ORM
# models.py
from django.db import models
class User(models.Model):
email = models.EmailField(unique=True)
role = models.CharField(max_length=20, default='user')
class Meta:
indexes = [models.Index(fields=['role'])]
class Post(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='posts')
# Operaciones CRUD
user = User.objects.create(email='alice@example.com', role='admin')
users = User.objects.filter(role='admin').prefetch_related('posts') # Carga anticipada
user.role = 'superadmin'
user.save()
user.delete()
# Creación masiva
User.objects.bulk_create([
User(email=f'user{i}@example.com') for i in range(100)
])
# Actualización masiva
User.objects.filter(role='user').update(role='member')
# Agregación
from django.db.models import Count
User.objects.annotate(post_count=Count('posts')).filter(post_count__gt=5)
Repositorio Spring Data JPA
public interface UserRepository extends JpaRepository<User, Integer> {
// Métodos de consulta derivados
Optional<User> findByEmail(String email);
List<User> findByRole(String role);
// Consultas personalizadas con @Query
@Query("SELECT u FROM User u WHERE u.role = :role ORDER BY u.email")
List<User> findByRoleOrdered(@Param("role") String role);
// Consulta nativa
@Query(value = "SELECT * FROM users WHERE email LIKE :pattern",
nativeQuery = true)
List<User> findByEmailPattern(@Param("pattern") String pattern);
// Consultas de modificación
@Modifying
@Query("UPDATE User u SET u.role = :role WHERE u.id = :id")
int updateRole(@Param("id") Integer id, @Param("role") String role);
// Paginación y ordenamiento
Page<User> findByRole(String role, Pageable pageable);
}
// Uso con paginación
Pageable pageable = PageRequest.of(0, 20, Sort.by("email").ascending());
Page<User> adminPage = userRepository.findByRole("admin", pageable);
TypeORM (TypeScript)
import { Entity, PrimaryGeneratedColumn, Column, OneToMany, ManyToOne } from 'typeorm';
@Entity()
class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
email: string;
@Column({ default: 'user' })
role: string;
@OneToMany(() => Post, post => post.author)
posts: Post[];
}
@Entity()
class Post {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@ManyToOne(() => User, user => user.posts)
author: User;
}
// Patrón repositorio
const userRepo = dataSource.getRepository(User);
// Crear
const user = userRepo.create({ email: 'alice@example.com', role: 'admin' });
await userRepo.save(user);
// Leer con relaciones
const users = await userRepo.find({
where: { role: 'admin' },
relations: ['posts'],
order: { email: 'ASC' },
take: 20,
skip: 0,
});
// Operaciones masivas
await userRepo
.createQueryBuilder()
.update()
.set({ role: 'member' })
.where('role = :role', { role: 'user' })
.execute();
Lo que Funciona
- Define esquemas y restricciones explícitos en el ORM, no solo en la base de datos: el modelo es documentación que no se degrada.
- Envuelve las operaciones de varios pasos en transacciones para que un paso fallido no deje el estado a medias.
- Añade índices en la base de datos para las columnas que filtras o unes; el ORM no los va a inventar por ti.
- Usa carga anticipada (
selectinload,include,JOIN FETCH) cuando sabes que vas a tocar una relación: el N+1 es el fallo por defecto, no la excepción. - Mantén las entidades delgadas: detalles de persistencia en el modelo, reglas de negocio en una capa de servicios.
Errores Comunes
- Forzar consultas analíticas o de reporting a través del ORM: el SQL generado para agregaciones complejas suele ser mediocre; baja a SQL directo en esos casos.
- Cargar relaciones dentro de bucles y descubrir el problema N+1 en producción.
- Meter lógica de negocio dentro de las clases de entidad, lo que las hace imposibles de probar sin base de datos.
- Leer atributos de carga diferida fuera de una sesión de Hibernate y chocar con
LazyInitializationException. - No cerrar nunca sesiones ni clientes de Prisma: cada fuga retiene una conexión del pool.
Consejos de Rendimiento
- Usa
lean()en Mongoose o proyecciones DTO en Hibernate. Omite la hidratación del ORM en consultas de solo lectura:
// Mongoose: devuelve objetos planos, no documentos de Mongoose
const users = await User.find().lean().exec();
// Proyección DTO en Hibernate
@Query("SELECT new com.app.dto.UserDTO(u.id, u.email) FROM User u WHERE u.role = :role")
List<UserDTO> findDTOsByRole(@Param("role") String role);
- Usa paginación por cursor en lugar de offset. La paginación por offset se vuelve más lenta cuanto más avanzas:
# Mal: paginación por offset (lenta con offsets grandes)
session.scalars(select(User).offset(100000).limit(20)).all()
# Bien: paginación por cursor (tiempo constante)
last_id = 100000
session.scalars(
select(User).where(User.id > last_id).order_by(User.id).limit(20)
).all()
- Activa la caché de segundo nivel en Hibernate. Guarda en caché las entidades que se leen con frecuencia:
@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class User {
// ...
}
- Ejecuta
EXPLAIN ANALYZEsobre las consultas que genera el ORM. El SQL producido puede no estar usando los índices de forma eficiente. Verifica siempre conEXPLAIN:
EXPLAIN ANALYZE SELECT users.id, users.email, users.role
FROM users
WHERE users.role = 'admin'
ORDER BY users.email;
- Desactiva el autoflush durante operaciones masivas. SQLAlchemy sincroniza la sesión antes de cada consulta por defecto, trabajo desperdiciado en inserciones por lotes:
with session.no_autoflush:
session.add_all(bulk_users)
Ver También
- Ejemplos ejecutables en el repositorio complementario — el mismo CRUD de User/Post en cinco ORMs, listo para correr.
- Quickstart del ORM de SQLAlchemy 2.x — sesiones,
select()y estrategias de carga de relaciones. - Referencia CRUD de Prisma — todas las variantes de
create/find/update/delete, transacciones y consultas crudas. - Guía de usuario de Hibernate ORM — contexto de persistencia, fetching y caché.
- Querysets de Django —
select_relatedvsprefetch_relatedy agregación.
Preguntas frecuentes
¿Debería usar un ORM o SQL directo?
Usa un ORM para CRUD, relaciones y migraciones: elimina mucho código repetitivo. Para agregaciones complejas, reportes y rutas críticas de rendimiento, escribe SQL directo y mantenlo parametrizado. La mayoría de los proyectos reales acaban usando ambos.
¿Cómo evito las consultas N+1 con un ORM?
Carga las relaciones por adelantado antes de iterar: selectinload en SQLAlchemy, include en Prisma, JOIN FETCH en Hibernate, prefetch_related en Django. Después vigila el número de consultas en desarrollo: en cuanto escala con la cantidad de filas, tienes un N+1.
¿Los ORMs pueden gestionar migraciones de base de datos?
Sí. SQLAlchemy se combina con Alembic, Prisma incluye Prisma Migrate, y Hibernate puede autogenerar el esquema con hbm2ddl. Trata las migraciones autogeneradas como borradores: revísalas antes de ejecutarlas en producción.
¿Cuándo debería evitar un ORM?
En ETL masivo, reporting pesado y cualquier ruta donde importen los microsegundos, el ORM estorba. Esos trabajos piden SQL afinado a mano, y el coste de mantener la abstracción supera lo que te ahorra.
¿Los ORMs previenen la inyección SQL?
Las consultas parametrizadas del ORM escapan los valores por defecto, pero los fragmentos en crudo son tu responsabilidad: prisma.$queryRaw sin bindings, session.execute(text(...)) con f-strings o un nativeQuery concatenado son todos inyectables. Vincula parámetros; nunca concatenes entrada del usuario.
Recursos Relacionados
Conectar a PostgreSQL
Cómo conectar a bases de datos PostgreSQL en Python, JavaScript y Java.
RecipeConectar a MySQL
Cómo conectar a MySQL en Python, JavaScript y Java — con pool de conexiones, SSL y consultas preparadas que aguantan en producción.
RecipeOperaciones CRUD con MongoDB y Mongoose
Como realizar operaciones Crear, Leer, Actualizar y Eliminar en MongoDB usando Mongoose ODM con Node.js y Express
RecipeEjecutar SQL Crudo
Cómo ejecutar consultas SQL crudas de forma segura con sentencias parametrizadas.
RecipeTransacciones de Base de Datos en Python, JS y Java
Cómo usar transacciones ACID para garantizar integridad de datos en Python, JavaScript y Java con ejemplos SQL.
GuideNormalización de Bases de Datos — 1NF a 5NF Explicado
Guía visual de normalización de bases de datos: aprende 1NF a 5NF con ejemplos prácticos, cuándo aplicar cada forma y cómo balancear normalización con rendimiento.