Skip to content

🎯 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).

bash
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 usan uv (de Astral), un gestor ultrarrápido escrito en Rust:

bash
uv init tienda-flask && cd tienda-flask
uv add flask python-dotenv
uv run flask run

uv reemplaza pip, venv y pip-tools de un golpe. Es el futuro del tooling Python.


4.3 · "Hola mundo" y primera ruta

python
# 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)
bash
flask --app app run --debug --port 8000
# o simplemente: python app.py

Abre 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
python
# 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
python
# 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:

bash
pip install Flask-SQLAlchemy Flask-Migrate psycopg[binary]

Define el modelo (equivale al modelo de Laravel):

python
# 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):

bash
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 BD

4.6 · CRUD completo (el blueprint)

python
# 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 con db.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):

bash
pip install marshmallow
python
# 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))
python
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(), 201

4.8 · Manejo de errores global

Define respuestas JSON consistentes para los errores (en la factory):

python
@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"}, 500

4.9 · Tests con pytest

El estándar de testing en Python es pytest:

bash
pip install pytest
python
# 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()
python
# 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 == 404
bash
pytest -v
pytest --cov=app          # con cobertura (pip install pytest-cov)

💡 Tip: el conftest.py con 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:

bash
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=True en 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):

bash
pip install flask[async]      # instala el soporte async (Asgiref por debajo)
python
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_task dentro 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:

bash
pip install Flask-Limiter Flask-Caching
python
# 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"})
python
# 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_when para 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 un PUT/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):

bash
pip install rq redis
python
# 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}"
python
# 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
bash
# 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

  1. Application factory + blueprints desde el día 1. Nada de un app.py de 1000 líneas.
  2. Entorno virtual siempre. Congela dependencias: pip freeze > requirements.txt.
  3. Valida con schemas (Marshmallow/Pydantic), no con if sueltos.
  4. db.session.commit() explícito y maneja errores con try/except + rollback().
  5. Config por entorno vía variables de entorno (.env), nunca hardcodeada.
  6. Gunicorn + Nginx en producción.
  7. Tests con BD en memoria (SQLite) para que corran rápido.
  8. 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.
  9. 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, y articulo_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.