🎯 Meta: entender el backend "desde abajo". Flask es minimalista: no te da nada hecho, así que ves con claridad cada pieza (rutas, request, response, BD). Es el contrapunto perfecto a Laravel, que te lo daba todo.
Versiones: Python 3.13 · Flask 3.1.3.
4.1 · ¿Por qué Flask?
- Micro-framework: el núcleo es diminuto; tú eliges cada pieza (ORM, validación…). Aprendes qué hace cada cosa porque la añades a mano.
- Python es el lenguaje más fácil de leer → ideal para consolidar conceptos.
- Perfecto para APIs pequeñas, prototipos, microservicios y proyectos de datos/IA.
🧠 Laravel vs Flask (mentalidad): Laravel dice "hazlo a mi manera y ve rápido". Flask dice "aquí tienes lo mínimo, construye a tu gusto". Ambos enfoques son válidos; conocer los dos te hace mejor ingeniero.
4.2 · Instalación y entorno virtual
Regla nº1 de Python: cada proyecto en su propio entorno virtual (aísla las dependencias).
python --version # debe ser 3.13 (o 3.10+)
mkdir tienda-flask && cd tienda-flask
python -m venv .venv # crea el entorno virtual
# Activarlo:
# Windows (PowerShell):
.venv\Scripts\Activate.ps1
# Mac/Linux:
source .venv/bin/activate
pip install Flask==3.1.3 python-dotenv💡 Tip moderno (2026): en vez de
pip+venv, muchos usanuv(de Astral), un gestor ultrarrápido escrito en Rust:bashuv init tienda-flask && cd tienda-flask uv add flask python-dotenv uv run flask run
uvreemplaza pip, venv y pip-tools de un golpe. Es el futuro del tooling Python.
4.3 · "Hola mundo" y primera ruta
# app.py
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/")
def home():
return jsonify({"mensaje": "¡Hola, backend!"})
@app.route("/salud")
def salud():
return {"estado": "ok"}, 200 # dict → JSON automático + código
if __name__ == "__main__":
app.run(debug=True, port=8000)flask --app app run --debug --port 8000
# o simplemente: python app.pyAbre http://localhost:8000. El decorador @app.route(...) conecta una URL con una función (vista). debug=True recarga al guardar y muestra errores detallados (solo en desarrollo).
4.4 · Estructura de proyecto (Application Factory + Blueprints)
Un app.py gigante no escala. La estructura profesional usa factory y blueprints (módulos):
tienda-flask/
├── app/
│ ├── __init__.py ← create_app() : la "fábrica" de la app
│ ├── extensions.py ← db, migrate... instancias compartidas
│ ├── models.py ← modelos SQLAlchemy
│ ├── schemas.py ← validación/serialización (Marshmallow)
│ └── productos/
│ ├── __init__.py
│ └── routes.py ← blueprint con los endpoints de productos
├── tests/
│ └── test_productos.py
├── .env
├── .flaskenv
└── requirements.txt# app/__init__.py
from flask import Flask
from .extensions import db, migrate
def create_app(config=None):
app = Flask(__name__)
app.config.from_prefixed_env() # lee variables FLASK_*
app.config["SQLALCHEMY_DATABASE_URI"] = \
"postgresql+psycopg://postgres:secreto@localhost:5432/tienda"
db.init_app(app)
migrate.init_app(app, db)
from .productos.routes import bp as productos_bp
app.register_blueprint(productos_bp, url_prefix="/api/productos")
return app# app/extensions.py
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
db = SQLAlchemy()
migrate = Migrate()🧠 ¿Por qué una "factory"? Poder crear varias instancias de la app con distinta config (una para producción, otra para tests). Es el patrón que Flask recomienda para proyectos serios.
4.5 · Base de datos con SQLAlchemy (el ORM)
Flask no trae ORM; el estándar es SQLAlchemy (+ Flask-SQLAlchemy). Instálalo:
pip install Flask-SQLAlchemy Flask-Migrate psycopg[binary]Define el modelo (equivale al modelo de Laravel):
# app/models.py
from datetime import datetime, timezone
from sqlalchemy.orm import Mapped, mapped_column
from .extensions import db
class Producto(db.Model):
__tablename__ = "productos"
id: Mapped[int] = mapped_column(primary_key=True)
nombre: Mapped[str] = mapped_column(db.String(255))
precio: Mapped[float] = mapped_column(db.Numeric(10, 2))
stock: Mapped[int] = mapped_column(default=0)
activo: Mapped[bool] = mapped_column(default=True)
creado_en: Mapped[datetime] = mapped_column(
default=lambda: datetime.now(timezone.utc)
)
def to_dict(self):
return {
"id": self.id,
"nombre": self.nombre,
"precio": float(self.precio),
"disponible": self.stock > 0,
}Migraciones con Flask-Migrate (usa Alembic por debajo):
flask db init # solo la primera vez
flask db migrate -m "crear productos" # genera la migración mirando los modelos
flask db upgrade # aplica los cambios a la BD4.6 · CRUD completo (el blueprint)
# app/productos/routes.py
from flask import Blueprint, request, jsonify, abort
from app.extensions import db
from app.models import Producto
bp = Blueprint("productos", __name__)
# GET /api/productos → listar (con paginación)
@bp.get("/")
def listar():
page = request.args.get("page", 1, type=int)
pagination = db.paginate(
db.select(Producto).order_by(Producto.creado_en.desc()),
page=page, per_page=15,
)
return jsonify({
"data": [p.to_dict() for p in pagination.items],
"total": pagination.total,
"pagina": page,
})
# GET /api/productos/<id> → ver uno
@bp.get("/<int:id>")
def ver(id):
producto = db.get_or_404(Producto, id) # 404 automático si no existe
return producto.to_dict()
# POST /api/productos → crear
@bp.post("/")
def crear():
datos = request.get_json()
# Validación manual (en el próximo cap. veremos hacerla automática):
if not datos or not datos.get("nombre"):
abort(422, description="El nombre es obligatorio")
if datos.get("precio", -1) < 0:
abort(422, description="El precio no puede ser negativo")
producto = Producto(
nombre=datos["nombre"],
precio=datos["precio"],
stock=datos.get("stock", 0),
)
db.session.add(producto)
db.session.commit()
return producto.to_dict(), 201
# PUT /api/productos/<id> → editar
@bp.put("/<int:id>")
def editar(id):
producto = db.get_or_404(Producto, id)
datos = request.get_json()
producto.nombre = datos.get("nombre", producto.nombre)
producto.precio = datos.get("precio", producto.precio)
producto.stock = datos.get("stock", producto.stock)
db.session.commit()
return producto.to_dict()
# DELETE /api/productos/<id> → borrar
@bp.delete("/<int:id>")
def borrar(id):
producto = db.get_or_404(Producto, id)
db.session.delete(producto)
db.session.commit()
return "", 204🧠 Fíjate en el patrón
session: en SQLAlchemy los cambios se "acumulan" en la sesión (db.session.add/delete) y se confirman todos juntos condb.session.commit(). Eso es una transacción (cap. 01). Si algo falla antes del commit, nada se guarda.
4.7 · Validación con Marshmallow (o Pydantic)
Validar a mano con if no escala. Se usa Marshmallow (o Pydantic, que verás en FastAPI):
pip install marshmallow# app/schemas.py
from marshmallow import Schema, fields, validate
class ProductoSchema(Schema):
nombre = fields.Str(required=True, validate=validate.Length(min=1, max=255))
precio = fields.Float(required=True, validate=validate.Range(min=0))
stock = fields.Int(load_default=0, validate=validate.Range(min=0))from marshmallow import ValidationError
from app.schemas import ProductoSchema
@bp.post("/")
def crear():
try:
datos = ProductoSchema().load(request.get_json()) # valida y limpia
except ValidationError as err:
return {"errores": err.messages}, 422
producto = Producto(**datos)
db.session.add(producto)
db.session.commit()
return producto.to_dict(), 2014.8 · Manejo de errores global
Define respuestas JSON consistentes para los errores (en la factory):
@app.errorhandler(404)
def no_encontrado(e):
return {"error": "Recurso no encontrado"}, 404
@app.errorhandler(422)
def no_procesable(e):
return {"error": str(e.description)}, 422
@app.errorhandler(500)
def error_servidor(e):
return {"error": "Error interno del servidor"}, 5004.9 · Tests con pytest
El estándar de testing en Python es pytest:
pip install pytest# tests/conftest.py
import pytest
from app import create_app
from app.extensions import db
@pytest.fixture
def app():
app = create_app()
app.config.update(
TESTING=True,
SQLALCHEMY_DATABASE_URI="sqlite:///:memory:", # BD en memoria para tests
)
with app.app_context():
db.create_all()
yield app
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()# tests/test_productos.py
def test_listar_vacio(client):
resp = client.get("/api/productos/")
assert resp.status_code == 200
assert resp.get_json()["total"] == 0
def test_crear_producto(client):
resp = client.post("/api/productos/", json={"nombre": "Teclado", "precio": 99.9})
assert resp.status_code == 201
assert resp.get_json()["nombre"] == "Teclado"
def test_crear_sin_nombre_falla(client):
resp = client.post("/api/productos/", json={"precio": 10})
assert resp.status_code == 422
def test_ver_inexistente_404(client):
resp = client.get("/api/productos/999")
assert resp.status_code == 404pytest -v
pytest --cov=app # con cobertura (pip install pytest-cov)💡 Tip: el
conftest.pycon fixtures es el corazón del testing en pytest. Una fixture es "algo que preparas antes del test" (una app, un cliente, datos). Se inyectan por nombre en los argumentos de la función de test. Muy elegante.
4.10 · Servir en producción (Gunicorn)
El servidor de desarrollo de Flask (flask run) no es para producción (lento, mono-hilo). En producción se usa un servidor WSGI como Gunicorn:
pip install gunicorn
gunicorn "app:create_app()" --workers 4 --bind 0.0.0.0:8000--workers 4 = 4 procesos en paralelo (regla: 2 × núcleos + 1). Delante irá Nginx como reverse proxy (cap. 13).
⚠️ Nunca despliegues con
app.run()/debug=Trueen producción: es inseguro (permite ejecutar código desde el navegador si hay un error) y no aguanta carga.
4.11 · Vistas async — cuándo Flask 3 ayuda y cuándo no
Desde Flask 2, las vistas pueden ser async def. Útil para I/O concurrente (varias llamadas a APIs externas en paralelo); inútil para trabajo que consume CPU (no lo hace más rápido, solo añade overhead del event loop):
pip install flask[async] # instala el soporte async (Asgiref por debajo)import httpx
@bp.get("/productos/<int:id>/precio-mercado")
async def precio_mercado(id):
producto = db.get_or_404(Producto, id)
async with httpx.AsyncClient() as client:
# dos llamadas externas EN PARALELO, no una tras otra
resp_a, resp_b = await asyncio.gather(
client.get(f"https://proveedor-a.com/precio/{producto.sku}"),
client.get(f"https://proveedor-b.com/precio/{producto.sku}"),
)
return {"proveedor_a": resp_a.json(), "proveedor_b": resp_b.json()}⚠️ Límite importante: una vista async de Flask sigue ejecutándose una a la vez por worker (Flask no es un servidor async como FastAPI/Elysia) — gana concurrencia dentro de una petición (varias llamadas I/O en paralelo), no entre peticiones. Y no puedes lanzar tareas de fondo con
asyncio.create_taskdentro de una vista: para trabajo en segundo plano usa una cola de tareas real (4.13), no async suelto.🔗 Si tu proyecto es async-first de verdad (no solo una vista puntual), el capítulo 05 (FastAPI) es el camino natural: está diseñado para eso desde la base.
4.12 · Rate limiting y caché de respuestas
Dos extensiones que resuelven problemas de producción que un if a mano no cubre bien:
pip install Flask-Limiter Flask-Caching# app/extensions.py — añade a las instancias compartidas
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_caching import Cache
limiter = Limiter(key_func=get_remote_address) # limita por IP por defecto
cache = Cache(config={"CACHE_TYPE": "RedisCache", "CACHE_REDIS_URL": "redis://localhost:6379/1"})# app/productos/routes.py
from app.extensions import limiter, cache
@bp.post("/")
@limiter.limit("10/minute") # máximo 10 creaciones por minuto por IP
def crear():
...
@bp.get("/<int:id>")
@cache.cached(timeout=60, query_string=True) # cachea la respuesta 60s (Redis, apéndice B)
def ver(id):
...💡
exempt_whenpara excepciones de negocio:@limiter.limit("100/day", exempt_when=lambda: current_user.is_admin)libera del límite a ciertos usuarios sin duplicar la ruta. Y recuerda invalidar el caché al escribir:cache.delete(f"vista/{id}")tras unPUT/DELETE— un caché que nunca se invalida es un bug de datos obsoletos esperando a pasar (apéndice B.3).
4.13 · Tareas en segundo plano (Celery / RQ)
Flask, a diferencia de Laravel (3.14), no trae un sistema de colas propio. Para trabajo que no debe bloquear la petición (enviar emails, procesar imágenes, llamar APIs lentas) se añade Celery (el más completo) o RQ (más simple, basado en Redis puro):
pip install rq redis# app/tasks.py
from redis import Redis
from rq import Queue
cola = Queue(connection=Redis.from_url("redis://localhost:6379/0"))
def enviar_confirmacion(pedido_id: int):
pedido = Pedido.query.get(pedido_id)
# ... enviar email real ...
return f"Confirmación enviada para pedido {pedido_id}"# En la vista: encolar en vez de ejecutar en la petición
from app.tasks import cola, enviar_confirmacion
@bp.post("/<int:id>/confirmar")
def confirmar(id):
cola.enqueue(enviar_confirmacion, id) # vuelve al instante, el worker lo procesa aparte
return {"encolado": True}, 202# Worker aparte, un proceso independiente de tu servidor Gunicorn:
rq worker⚠️ Igual que en Laravel (3.14), las colas son at-least-once. Si el worker muere a mitad de
enviar_confirmacion, RQ la reintenta desde cero. Diseña tus tareas para que ejecutarlas dos veces no duplique el efecto (idempotencia) — el mismo principio, sin importar el framework.
4.14 · Buenas prácticas Flask
- Application factory + blueprints desde el día 1. Nada de un
app.pyde 1000 líneas. - Entorno virtual siempre. Congela dependencias:
pip freeze > requirements.txt. - Valida con schemas (Marshmallow/Pydantic), no con
ifsueltos. db.session.commit()explícito y maneja errores con try/except +rollback().- Config por entorno vía variables de entorno (
.env), nunca hardcodeada. - Gunicorn + Nginx en producción.
- Tests con BD en memoria (SQLite) para que corran rápido.
- Trabajo lento → cola (RQ/Celery), nunca dentro de la petición. Async ayuda con I/O concurrente puntual, no sustituye a una cola de tareas real.
- Rate limiting en endpoints de escritura y de autenticación — sin él, un bucle mal escrito (tuyo o de un atacante) puede tumbar tu API o tu factura de infraestructura.
✅ Ejercicio del capítulo
Reconstruye la API del blog (la misma del cap. 00 y 03) pero en Flask:
1. Factory + blueprints (articulos, comentarios).
2. Modelos SQLAlchemy con relación Articulo↔Comentario (relationship + ForeignKey).
3. CRUD completo con paginación.
4. Schemas Marshmallow para validar.
5. Manejo de errores 404/422 global.
6. Al menos 5 tests con pytest y BD en memoria.
7. Añade rate limiting (10/minuto) al endpoint de crear comentarios.
8. Encola con RQ el envío de una notificación al crear un comentario, en vez de
hacerlo dentro de la petición.Reflexiona: ¿qué te dio Laravel "gratis" que aquí tuviste que montar tú? Esa es la diferencia entre un framework "todo incluido" y un micro-framework. Ninguno es mejor: depende del proyecto.
💡 Pistas de la solución (abre solo si te atascas)
- Cosas que Laravel te dio gratis y aquí montas a mano: el ORM con migraciones integradas (aquí es Flask-Migrate aparte), la validación por Form Request (aquí es un schema Marshmallow explícito), y las Resources para dar forma al JSON (aquí lo haces con
schema.dump()). - Para la relación Articulo↔Comentario:
comentarios = db.relationship('Comentario', backref='articulo', lazy=True)en el modelo padre, yarticulo_id = db.Column(db.Integer, db.ForeignKey('articulo.id'))en el hijo. - Los tests con BD en memoria necesitan crear las tablas en un fixture con scope de función (
db.create_all()antes de cada test,db.drop_all()después) para que no se contaminen entre sí.
🧠 Autoevaluación
Permite crear múltiples instancias de la app con configuraciones distintas (una para tests con BD en memoria, otra para producción) sin duplicar código. Sin factory, la configuración queda fijada al importar el módulo, lo que complica mucho testear con una BD aislada.
Porque SQLAlchemy mantiene una sesión con cambios pendientes; si un commit falla a mitad (violación de constraint, error de conexión), la sesión queda en un estado inconsistente que bloquea operaciones posteriores hasta que se revierte explícitamente con db.session.rollback().
Velocidad y aislamiento: una BD en memoria se crea y destruye en milisegundos, sin necesidad de un servicio externo corriendo, y cada test empieza desde cero sin datos residuales de ejecuciones anteriores. El trade-off es que SQLite no soporta el 100% de features de PostgreSQL — para tests que dependan de eso, se usa un Postgres real vía Testcontainers (cap. 09).
Un micro-framework da más control y menos "magia" — mejor cuando el equipo quiere decidir cada pieza (ORM, validación, auth) o el proyecto tiene necesidades no convencionales. Un framework todo-incluido acelera el desarrollo estándar con convenciones ya decididas — mejor cuando quieres velocidad y el proyecto encaja en el "camino feliz" que el framework anticipó.
Porque async solo aporta ventaja cuando hay espera de I/O que se puede solapar (varias llamadas de red en paralelo); el trabajo de CPU sigue ocupando el hilo del worker igual que en una vista síncrona, y además añade el overhead de gestionar el event loop sin ganar nada a cambio.
Porque Flask es deliberadamente un micro-framework: no decide por ti qué sistema de colas usar, igual que no decide tu ORM o tu validador. Es el mismo trade-off del punto 4 — más piezas que elegir y conectar tú mismo, a cambio de no cargar con un sistema que quizás no necesites.
Siguiente: 05-python-fastapi.md — Python moderno: tipos, async y docs automáticas.