Semestre 01, 2026
Una aplicación necesita buscar un producto por su ID.
import psycopg2
conn = psycopg2.connect("dbname=tienda user=app password=clave")
cursor = conn.cursor()
cursor.execute(
"SELECT id, nombre, precio, stock FROM productos WHERE id = %s",
(producto_id,)
)
fila = cursor.fetchone()
if fila:
producto = {
"id": fila[0],
"nombre": fila[1],
"precio": fila[2],
"stock": fila[3]
}
cursor.close()
conn.close()
Una consulta simple
Funciona. Pero hay que repetir esta estructura para cada tabla, cada operación.
SELECT, INSERT, UPDATE, DELETE.Con 10 tablas, el código de acceso a datos se vuelve masivo.
# ¿Esto es lógica de negocio o SQL?
def aprobar_pedido(pedido_id):
cursor.execute(
"UPDATE pedidos SET estado = 'aprobado', "
"fecha_aprobacion = NOW() WHERE id = %s",
(pedido_id,)
)
cursor.execute(
"UPDATE inventario SET reservado = reservado - cantidad "
"FROM pedido_detalle WHERE pedido_id = %s",
(pedido_id,)
)
conn.commit()
Código mezclado
La lógica de negocio y el SQL están entrelazados.
Cambiar la base de datos implica reescribir la lógica.
Object Relational Mapping — Mapeo Objeto-Relacional.
Una capa de abstracción que traduce entre dos mundos.
| Mundo relacional | Mundo orientado a objetos |
|---|---|
| Tabla | Clase |
| Fila | Objeto / instancia |
| Columna | Atributo |
| Clave primaria | Identificador del objeto |
| Clave foránea | Referencia a otro objeto |
| JOIN | Acceso a objeto relacionado |
En lugar de escribir SQL, se trabaja con objetos del lenguaje de programación. El ORM traduce automáticamente.
Sin ORM
cursor.execute(
"SELECT * FROM productos WHERE precio < %s",
(100,)
)
Con ORM
productos = Producto.query.filter(
Producto.precio < 100
).all()
Mismo resultado, distinta abstracción.
Se define la tabla como una clase:
class Producto(Base):
__tablename__ = "productos"
id = Column(Integer, primary_key=True)
nombre = Column(String(200), nullable=False)
precio = Column(Numeric(10, 2), nullable=False)
stock = Column(Integer, default=0)
A partir de esa definición, el ORM sabe cómo construir el SQL necesario.
El más completo y flexible. Compatible con PostgreSQL, MySQL, SQLite, Oracle y más.
Integrado en Django. Muy productivo: genera migraciones y panel de administración automáticamente.
Patrón Active Record. Sintaxis fluida y expresiva. Ampliamente usado en aplicaciones web.
El más maduro del ecosistema Java. Base de JPA. Estándar en aplicaciones empresariales.
ORM moderno con cliente tipado. Errores detectados en tiempo de compilación.
| ORM | Lenguaje | Estilo | Compatibilidad principal |
|---|---|---|---|
| SQLAlchemy | Python | Data Mapper | PostgreSQL, MySQL, SQLite |
| Django ORM | Python | Active Record | PostgreSQL, MySQL, SQLite |
| Eloquent | PHP | Active Record | MySQL, PostgreSQL, SQLite |
| ORM | Lenguaje | Estilo | Compatibilidad principal |
|---|---|---|---|
| Hibernate | Java | Data Mapper | PostgreSQL, MySQL, Oracle |
| Prisma | TypeScript | Data Mapper | PostgreSQL, MySQL, SQLite |
from sqlalchemy import create_engine
# PostgreSQL
engine = create_engine("postgresql+psycopg2://usuario:clave@localhost/tienda")
# MySQL
engine = create_engine("mysql+pymysql://usuario:clave@localhost/tienda")
Solo cambia la cadena de conexión — el código ORM es idéntico para ambos motores.
from sqlalchemy import Column, Integer, String, Numeric, ForeignKey
from sqlalchemy.orm import DeclarativeBase, relationship
class Base(DeclarativeBase):
pass
class Categoria(Base):
__tablename__ = "categorias"
id = Column(Integer, primary_key=True)
nombre = Column(String(100), nullable=False, unique=True)
productos = relationship("Producto", back_populates="categoria")
class Producto(Base):
__tablename__ = "productos"
id = Column(Integer, primary_key=True)
nombre = Column(String(200), nullable=False)
precio = Column(Numeric(10, 2), nullable=False)
stock = Column(Integer, default=0)
categoria_id = Column(Integer, ForeignKey("categorias.id"))
categoria = relationship("Categoria", back_populates="productos")
from sqlalchemy.orm import Session
with Session(engine) as session:
nuevo = Producto(
nombre="Laptop",
precio=5000.00,
stock=10,
categoria_id=1
)
session.add(nuevo)
session.commit()
Insertar un registro
Se crea un objeto Python normal. session.add() lo registra. session.commit() genera y ejecuta el SQL.
from sqlalchemy.orm import Session
with Session(engine) as session:
nuevo = Producto(
nombre="Laptop",
precio=5000.00,
stock=10,
categoria_id=1
)
session.add(nuevo)
session.commit()
SQL generado internamente:
INSERT INTO productos
(nombre, precio, stock, categoria_id)
VALUES
('Laptop', 5000.00, 10, 1)
with Session(engine) as session:
# Todos los productos
todos = session.query(Producto).all()
# Por ID
uno = session.get(Producto, 5)
# Con filtros y orden
disponibles = session.query(Producto).filter(
Producto.stock > 0,
Producto.precio < 2000
).order_by(Producto.precio).all()
with Session(engine) as session:
producto = session.get(Producto, 5)
if producto:
producto.precio = 4500.00
producto.stock = 15
session.commit()
Solo modificar los atributos
El ORM detecta qué cambió y genera solo el UPDATE necesario.
with Session(engine) as session:
producto = session.get(Producto, 5)
if producto:
producto.precio = 4500.00
producto.stock = 15
session.commit()
SQL generado:
UPDATE productos
SET precio = 4500.00, stock = 15
WHERE productos.id = 5
Solo se actualiza lo que cambió.
with Session(engine) as session:
producto = session.get(Producto, 5)
# Sin escribir un JOIN
print(producto.nombre)
print(producto.categoria.nombre)
SQL ejecutado al acceder a .categoria:
SELECT categorias.id, categorias.nombre
FROM categorias
WHERE categorias.id = 1
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Producto extends Model
{
protected $table = 'productos';
protected $fillable = [
'nombre', 'precio', 'stock', 'categoria_id',
];
public function categoria()
{
return $this->belongsTo(Categoria::class);
}
}
// Insertar
$producto = Producto::create([
'nombre' => 'Laptop',
'precio' => 5000.00,
'stock' => 10,
'categoria_id' => 1,
]);
// Buscar por ID
$producto = Producto::find(5);
// Con filtros
$baratos = Producto::where('precio', '<', 1000)
->where('stock', '>', 0)
->orderBy('precio')
->get();
// Actualizar y eliminar
$producto->precio = 4500.00;
$producto->save();
$producto->delete();
Relaciones como atributos
$producto = Producto::find(5);
echo $producto->nombre;
// JOIN automático:
echo $producto->categoria->nombre;
Cambiar de motor en .env
# MySQL
DB_CONNECTION=mysql
DB_PORT=3306
# PostgreSQL
DB_CONNECTION=pgsql
DB_PORT=5432
El código de los modelos no cambia.
Construir SQL con concatenación de strings es la causa raíz de SQL Injection.
# Vulnerable — datos del usuario directamente en el SQL
query = f"SELECT * FROM usuarios WHERE email = '{email}'"
nombre_buscado = "'; DROP TABLE productos; --"
# Sin ORM — vulnerable
query = f"SELECT * FROM productos WHERE nombre = '{nombre_buscado}'"
# Ejecuta: SELECT * FROM productos WHERE nombre = ''; DROP TABLE productos; --'
# Con ORM — seguro
session.query(Producto).filter(Producto.nombre == nombre_buscado).all()
# Genera: SELECT * FROM productos WHERE nombre = $1
# Parámetro: "'; DROP TABLE productos; --" (texto, no SQL)
La tabla no se elimina. El input malicioso es buscado literalmente como nombre.
# Todo parametrizado automáticamente
session.add(Producto(nombre=nombre, precio=precio)) # INSERT parametrizado
session.query(Producto).filter(Producto.id == id) # SELECT parametrizado
producto.precio = nuevo_precio; session.commit() # UPDATE parametrizado
session.delete(producto) # DELETE por PK
El desarrollador no necesita recordar parametrizar — el ORM lo hace por defecto.
Cuando se escribe SQL crudo, puede reaparecer la vulnerabilidad.
SQLAlchemy — vulnerable
session.execute(
text(f"SELECT * FROM productos "
f"WHERE nombre = '{nombre}'")
)
SQLAlchemy — seguro
session.execute(
text("SELECT * FROM productos "
"WHERE nombre = :nombre"),
{"nombre": nombre}
)
Eloquent — vulnerable
DB::select(
"SELECT * FROM productos
WHERE nombre = '$nombre'"
);
Eloquent — seguro
DB::select(
"SELECT * FROM productos
WHERE nombre = ?",
[$nombre]
);
CRUD en pocas líneas. La lógica de acceso queda en el modelo.
Mismo código para PostgreSQL, MySQL y SQLite. Cambiar es cuestión de un parámetro.
Consultas parametrizadas automáticamente. Protección contra SQL Injection estructural.
Historial de cambios del esquema versionado junto con el código.
No hay que escribir JOINs. Se accede a datos relacionados como atributos.
Los modelos definen reglas que se validan antes de llegar a la base de datos.
| Aspecto | Sin ORM | Con ORM |
|---|---|---|
| SQL Injection | Manual — depende del desarrollador | Automático — parametrizado siempre |
| Código repetitivo | Alto — SQL para cada tabla | Bajo — el ORM genera el SQL |
| Cambio de motor DB | Reescribir todas las consultas | Cambiar la cadena de conexión |
| Migraciones | Scripts SQL manuales | Generadas automáticamente |
| Relaciones | JOINs escritos a mano | Acceso como atributos |
El ORM no es una solución perfecta para todo.
productos = session.query(Producto).all() # query 1: trae todos los productos
for producto in productos:
print(producto.categoria.nombre) # query 2, 3, 4 ... N+1
Parece inocente. Pero el ORM hace una query extra por cada producto al acceder a .categoria.
-- Query 1: traer los productos
SELECT * FROM productos; -- devuelve 1000 filas
-- Query 2: categoría del producto 1
SELECT * FROM categorias WHERE id = 3;
-- Query 3: categoría del producto 2
SELECT * FROM categorias WHERE id = 1;
-- Query 4: categoría del producto 3
SELECT * FROM categorias WHERE id = 3;
-- ...
-- Query 1001: categoría del producto 1000
SELECT * FROM categorias WHERE id = 7;
1000 productos = 1001 queries. El ORM las dispara en silencio.
from sqlalchemy.orm import joinedload
productos = session.query(Producto).options(
joinedload(Producto.categoria)
).all()
# Ahora esto NO hace queries extra
for producto in productos:
print(producto.categoria.nombre)
SQL generado:
SELECT productos.*, categorias.*
FROM productos
LEFT OUTER JOIN categorias
ON categorias.id = productos.categoria_id
1 query en lugar de 1001.
| Estrategia | ¿Cuándo carga la relación? | Queries |
|---|---|---|
| Lazy (por defecto) | Al acceder al atributo (.categoria) |
1 + N |
Eager (joinedload) |
En la misma query inicial con JOIN | 1 |
Lazy loading es cómodo para un objeto. Eager loading es obligatorio para listas.
# ¿Cuántas queries ejecuta esto?
for pedido in session.query(Pedido).all():
total = sum(d.cantidad * d.precio for d in pedido.detalles)
Puede ejecutar cientos de queries sin que el desarrollador lo note.
Los ORMs tienen su propia API. Usarlos bien requiere entender el modelo relacional.
Múltiples JOINs, subconsultas, funciones específicas del motor: el SQL manual suele ser más eficiente y claro.
El SQL generado no siempre es óptimo. Hay que revisar qué produce el ORM.
Procedimientos almacenados, triggers y funciones complejas requieren SQL crudo.
El ORM no reemplaza el conocimiento de SQL.
Indispensable para depurar problemas de rendimiento.
SQLAlchemy
print(
session.query(Producto)
.filter(Producto.precio < 1000)
)
Django ORM
print(
Producto.objects
.filter(precio__lt=1000)
.query
)
Un ORM mal usado es más lento que SQL directo.
El ORM solo crea índice en la clave primaria. Los demás deben definirse explícitamente.
class Producto(Base):
__tablename__ = "productos"
id = Column(Integer, primary_key=True)
nombre = Column(String(200), index=True) # crea índice en nombre
precio = Column(Numeric(10, 2), nullable=False)
Sin los índices correctos, las consultas del ORM serán lentas igual que el SQL directo.
ORM para las operaciones del día a día, SQL directo para lo que el ORM no maneja bien.
# Llamar a un procedimiento almacenado desde el ORM
session.execute(text("CALL procesar_inventario_diario()"))
Las reglas de negocio complejas pueden seguir viviendo en procedimientos almacenados.