SQLModel combina modelos tipados, validación y persistencia sobre Pydantic y SQLAlchemy. Reduce repetición en APIs, pero no elimina las decisiones sobre transacciones, índices, relaciones y migraciones.
Crear una tabla y una sesión
from sqlmodel import Field, Session, SQLModel, create_engine, select
class Producto(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
nombre: str = Field(index=True, min_length=2, max_length=120)
precio_centavos: int = Field(gt=0)
engine = create_engine("sqlite:///tienda.db")
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
producto = Producto(nombre="Teclado", precio_centavos=15900)
session.add(producto)
session.commit()
session.refresh(producto)
resultados = session.exec(select(Producto).where(Producto.precio_centavos < 20000)).all()
Representa dinero con enteros en la unidad menor o configura Decimal de forma coherente. Consulta Decimal para cálculos monetarios.
Separar contratos y persistencia
Define ProductoCreate sin table=True, ProductoUpdate con campos opcionales y ProductoPublic solo con datos seguros. Así evitas que el cliente asigne IDs internos o lea columnas sensibles. En FastAPI, crea una sesión corta por petición mediante inyección de dependencias.
create_all() funciona en ejemplos, pero no gestiona la evolución de una base existente. Usa Alembic para migraciones, revisa el SQL y conserva copias de seguridad.
La documentación oficial de SQLModel, consultada el 22 de julio de 2026, cubre CRUD, relaciones y FastAPI. La comodidad no sustituye el conocimiento de constraints, transacciones y consultas.
Diseñar modelos para cada contrato
Un CRUD real debe distinguir los datos que envía el cliente, la fila almacenada y la respuesta pública. Una base compartida reduce repetición sin convertir la tabla en contrato externo:
class ProductoBase(SQLModel):
nombre: str = Field(min_length=2, max_length=120)
precio_centavos: int = Field(gt=0)
class Producto(ProductoBase, table=True):
id: int | None = Field(default=None, primary_key=True)
activo: bool = True
class ProductoCreate(ProductoBase):
pass
class ProductoUpdate(SQLModel):
nombre: str | None = Field(default=None, min_length=2, max_length=120)
precio_centavos: int | None = Field(default=None, gt=0)
activo: bool | None = None
class ProductoPublic(ProductoBase):
id: int
activo: bool
Esta separación impide que un POST elija el identificador interno. Para un PATCH, obtiene solamente los campos enviados con model_dump(exclude_unset=True) y aplica esos valores. No uses exclude_none=True de manera automática: en otros contratos, un null explícito puede significar que el usuario quiere borrar un valor opcional.
Implementar las operaciones CRUD
Mantén la transacción cerca de la operación. La creación puede recibir una sesión y los datos validados, construir Producto.model_validate(datos), añadir el objeto, confirmar y llamar a refresh(). Así recupera el ID generado y los valores predeterminados por la base.
En una lectura individual, busca la clave primaria y responde 404 si no existe. Una respuesta exitosa con null deja un contrato ambiguo. Las listas necesitan límites y orden estable:
def listar_productos(session: Session, offset: int = 0, limite: int = 50):
consulta = (
select(Producto)
.where(Producto.activo.is_(True))
.order_by(Producto.id)
.offset(offset)
.limit(min(limite, 100))
)
return session.exec(consulta).all()
La paginación por offset funciona bien en catálogos modestos. Para conjuntos grandes o con muchos cambios, evalúa cursores. Crea índices para filtros reales, teniendo en cuenta que cada índice consume espacio y encarece las escrituras. Examina el plan de ejecución antes de indexar por intuición.
Durante una actualización, carga y modifica la fila en la misma sesión. Si falla una restricción única, captura la excepción pertinente, ejecuta rollback() y convierte el conflicto en una respuesta 409. Una sesión que ha fallado no debe ejecutar más consultas antes del rollback.
La eliminación física sirve para datos descartables. Si pedidos existentes hacen referencia a un producto, suele ser mejor asignar activo=False. La eliminación lógica exige que todas las consultas aplicables excluyan filas inactivas. Centraliza esa política para evitar resultados contradictorios.
Gestionar la sesión en FastAPI
Crea una dependencia con una sesión por petición:
def obtener_sesion():
with Session(engine) as session:
yield session
El endpoint recibe session: Session = Depends(obtener_sesion). Una sesión no es una caché global y no debe compartirse entre hilos ni peticiones. La configuración de SQLite usada en pruebas puede ser distinta de la producción; no copies check_same_thread=False a otro motor sin comprender su objetivo.
Declara response_model=ProductoPublic o la anotación de retorno equivalente. OpenAPI documentará la representación pública y será menos probable que se filtren columnas internas. Sin embargo, filtrar la salida no reemplaza la autorización. Comprueba que la identidad actual puede consultar o modificar el recurso.
Tratar transacciones y concurrencia
Un commit() delimita una unidad de trabajo. Si crear un pedido y reducir existencias deben suceder juntos, realiza ambas modificaciones en una transacción. Confirmar a mitad del proceso y compensar después permite estados incompletos.
Dos peticiones pueden leer el mismo dato e intentar actualizarlo. Las constraints de la base son la defensa final de la unicidad y la integridad referencial. Los cambios de saldo o inventario pueden necesitar una actualización atómica, bloqueo de fila o concurrencia optimista con una columna de versión. La elección depende del motor y del aislamiento, no solo del ORM.
Vigila las consultas N+1 al recorrer relaciones. Observa el SQL generado y elige una estrategia de carga explícita cuando necesites datos relacionados. Tampoco devuelvas una colección ilimitada solo porque .all() resulta cómodo.
Probar el comportamiento completo
Sustituye la dependencia de sesión por una base temporal y aísla el estado de cada prueba. Incluye creación correcta, validación, recurso inexistente, conflicto de unicidad, paginación, autorización y filtrado de respuesta. Probar únicamente una función de repositorio no demuestra que el endpoint HTTP entregue el estado y el schema correctos.
SQLite agiliza las pruebas, pero sus tipos y su concurrencia difieren de PostgreSQL o MySQL. Conserva pruebas de integración con el mismo motor de producción para consultas, restricciones y migraciones críticas. Nunca uses create_all() como sistema de despliegue: crea tablas ausentes, pero no describe ni aplica de forma segura la evolución del schema.
Lista de control para producción
Antes de publicar, revisa límites de entrada, paginación, índices, constraints, rollback, autorización y campos expuestos. Registra operaciones útiles sin guardar datos sensibles. Mide cantidad y latencia de consultas, conserva copias de seguridad y prueba su restauración. SQLModel reduce código repetitivo, pero la fiabilidad depende de contratos explícitos y reglas garantizadas por la base.
Decide también dónde residen las reglas de negocio. La validación de formato corresponde a los modelos de entrada, mientras que una regla dependiente del estado, como impedir la desactivación de un producto incluido en una promoción vigente, debe consultar la base dentro del servicio. Evita ocultar decisiones importantes en eventos automáticos difíciles de seguir.
Planifica cambios compatibles. Añadir una columna opcional, desplegar código que comprenda ambos estados, completar los datos y solo entonces imponer la obligatoriedad suele ser más seguro que modificar todo de una vez. Mide consultas con un volumen parecido al real: una tabla vacía no revela planes deficientes, ordenaciones costosas ni páginas inestables.
Mantén funciones enfocadas y comprobables, pero evita un repositorio genérico que elimine diferencias esenciales. Un producto, un pago y una cuenta pueden ofrecer una operación llamada “actualizar”, aunque necesitan reglas de autorización, auditoría y concurrencia distintas. Algo de código explícito suele ser más mantenible que una abstracción llena de opciones ocultas.
Documenta esas decisiones junto al servicio y revísalas cuando cambien los requisitos.