Flask te da un servidor HTTP funcional en veinte líneas, pero la simplicidad superficial esconde decisiones de diseño que vale la pena entender antes de que tu aplicación crezca y empiece a doler.
El núcleo: rutas como decoradores y el contexto de request
Cuando escribes @app.route('/users/<int:id>'), estás registrando una regla en el URL map de Werkzeug (la librería WSGI que Flask envuelve). El <int:id> no es solo sintaxis bonita: activa un converter que llama a int() sobre el segmento capturado antes de que tu función lo reciba. Si llega /users/abc, Flask devuelve 404 automáticamente sin que tu código lo vea. Esto importa porque te quita una clase entera de validación de encima, pero también significa que no puedes atrapar ese error con un try/except en tu view function.
El objeto request merece atención especial. Flask lo expone como una variable global aparente, pero en realidad es un proxy de contexto local (LocalProxy). Cada request corre en su propio contexto y el proxy resuelve al objeto correcto según el hilo (o greenlet, si usas gevent). Si intentas acceder a request fuera de un contexto activo —en un test mal configurado, en un celery task, en un callback de señal— obtendrás RuntimeError: Working outside of request context. El diseño existe para evitar estado global real: sin el proxy, tendrías que pasar el objeto request como parámetro por toda tu cadena de llamadas.
flask.g funciona igual: es storage que vive exactamente lo que dura un request. Es el lugar correcto para cachear una conexión de base de datos o el usuario autenticado después de validar el token, y que esté disponible en cualquier punto de ese request sin pasar parámetros.
Cuándo usar Flask versus Django o FastAPI tiene una respuesta directa: Flask es la opción correcta cuando necesitas control granular sobre qué entra en tu stack (no quieres un ORM acoplado, tienes tu propio sistema de autenticación, o estás construyendo algo pequeño y predecible). Django gana cuando necesitas la batería completa lista para producción: admin, ORM, auth, migraciones. FastAPI gana cuando necesitas validación automática via Pydantic y documentación OpenAPI generada, especialmente en APIs puras. Lo que Flask no te da por defecto —y debes elegir conscientemente— es ORM (usa SQLAlchemy aparte), validación de esquemas (usa Marshmallow o Pydantic), y autenticación (usa Flask-Login o JWT propio).
# app/__init__.py
from flask import Flask
from app.routes.users import users_bp
from app.routes.auth import auth_bp
def create_app(config=None):
app = Flask(__name__)
app.config["SECRET_KEY"] = "dev-secret-change-in-prod"
if config:
app.config.update(config)
# Registrar blueprints con prefijo de URL
app.register_blueprint(users_bp, url_prefix="/api/v1/users")
app.register_blueprint(auth_bp, url_prefix="/api/v1/auth")
return app
# app/routes/users.py
from flask import Blueprint, request, jsonify, g, abort
users_bp = Blueprint("users", __name__)
# Simulación de base de datos en memoria
_USERS = {
1: {"id": 1, "name": "Ana García", "role": "admin"},
2: {"id": 2, "name": "Luis Torres", "role": "viewer"},
}
def _get_user_or_404(user_id: int) -> dict:
user = _USERS.get(user_id)
if user is None:
abort(404, description=f"User {user_id} not found")
return user
@users_bp.before_request
def attach_request_id():
# flask.g vive exactamente lo que dura este request
import uuid
g.request_id = str(uuid.uuid4())
@users_bp.route("/", methods=["GET"])
def list_users():
# request.args es un ImmutableMultiDict; .get() nunca lanza KeyError
role_filter = request.args.get("role")
page = request.args.get("page", default=1, type=int)
per_page = request.args.get("per_page", default=10, type=int)
users = list(_USERS.values())
if role_filter:
users = [u for u in users if u["role"] == role_filter]
# Paginación simple para mostrar query params en acción
start = (page - 1) * per_page
paginated = users[start : start + per_page]
return jsonify({
"data": paginated,
"page": page,
"total": len(users),
"request_id": g.request_id, # disponible gracias a before_request
}), 200
@users_bp.route("/<int:user_id>", methods=["GET"])
def get_user(user_id: int):
user = _get_user_or_404(user_id)
return jsonify(user), 200
@users_bp.route("/", methods=["POST"])
def create_user():
# request.json devuelve None si el Content-Type no es application/json
# o si el body no es JSON válido; no lanza excepción por defecto
data = request.get_json(silent=True)
if not data:
return jsonify({"error": "JSON body required"}), 400
name = data.get("name", "").strip()
role = data.get("role", "viewer")
if not name:
return jsonify({"error": "'name' is required"}), 422
new_id = max(_USERS.keys()) + 1
new_user = {"id": new_id, "name": name, "role": role}
_USERS[new_id] = new_user
# 201 Created es semánticamente correcto para recursos nuevos
return jsonify(new_user), 201
@users_bp.route("/<int:user_id>", methods=["PATCH"])
def update_user(user_id: int):
user = _get_user_or_404(user_id)
data = request.get_json(silent=True) or {}
# PATCH solo actualiza campos enviados, no reemplaza todo el recurso
if "name" in data:
user["name"] = data["name"].strip()
if "role" in data:
user["role"] = data["role"]
return jsonify(user), 200
@users_bp.errorhandler(404)
def not_found(error):
return jsonify({"error": str(error)}), 404
# run.py (punto de entrada para desarrollo)
from app import create_app
app = create_app()
if __name__ == "__main__":
app.run(debug=True, port=5000)
Lo que hace este código y por qué cada decisión importa
El patrón application factory (create_app) no es decorativo: te permite instanciar la app múltiples veces con configuraciones distintas, lo cual es imprescindible para tests. Sin él, la app se crea al importar el módulo y acabas con estado global que contamina tus suites de test.
Los blueprints son básicamente mini-aplicaciones que se montan sobre la app principal. users_bp = Blueprint("users", __name__) registra el nombre del blueprint, que Flask usa para el namespace de url_for. Cuando llamas url_for("users.get_user", user_id=1), el prefijo "users." evita colisiones de nombres entre blueprints. La función before_request decorada en el blueprint solo se dispara en requests que pertenecen a ese blueprint, no en toda la aplicación.
request.args.get("page", default=1, type=int) hace tres cosas en una: retorna 1 si el parámetro no existe, convierte a entero, y devuelve el default si la conversión falla. Esto es mucho más robusto que int(request.args["page"]), que lanza KeyError si falta y ValueError si no es un número.
La diferencia entre request.json y request.get_json(silent=True) es relevante en producción. La propiedad request.json lanza 400 Bad Request automáticamente si el Content-Type no es application/json. El método con silent=True devuelve None en ese caso y te deja manejar el error tú mismo con un mensaje más informativo.
g.request_id demuestra el flujo correcto de flask.g: se popula en before_request, se consume en la view function. Podrías igualmente guardar ahí una conexión de DB abierta al inicio del request y cerrarla en teardown_request. Lo que no debes hacer es usar g para almacenar estado entre requests distintos —muere con el request y el próximo empieza con un g vacío.
Errores que debes conocer
Error: usar request.json sin verificar el Content-Type hace que clientes que mandan application/x-www-form-urlencoded o body vacío reciban un 400 sin tu lógica de error personalizada.
# ❌ Wrong
@app.route("/items", methods=["POST"])
def create_item():
data = request.json # Lanza 400 automáticamente si Content-Type incorrecto
return jsonify({"name": data["name"]}), 201
# ✅ Right
@app.route("/items", methods=["POST"])
def create_item():
data = request.get_json(silent=True)
if data is None:
return jsonify({"error": "application/json body required"}), 400
return jsonify({"name": data["name"]}), 201
Con silent=True el control de errores queda en tus manos y puedes devolver un mensaje que el cliente entienda.
Error: registrar blueprints fuera del application factory crea dependencias circulares en cuanto tu blueprint importa modelos que importan db que importa la app.
# ❌ Wrong
# app.py
from flask import Flask
from routes.users import users_bp # importa en top-level
app = Flask(__name__)
app.register_blueprint(users_bp)
# ✅ Right
# app.py
from flask import Flask
def create_app():
app = Flask(__name__)
from routes.users import users_bp # importación diferida, dentro del factory
app.register_blueprint(users_bp)
return app
La importación diferida rompe el ciclo porque cuando se ejecuta, la app ya existe y los módulos que la necesitan pueden importarla sin problemas.
Error: acceder a request o g en un thread secundario lanzado dentro de una view function, porque el contexto de request no se propaga automáticamente a nuevos threads.
# ❌ Wrong
import threading
@app.route("/notify")
def notify():
def send():
user = g.current_user # RuntimeError: fuera de contexto
...
threading.Thread(target=send).start()
return jsonify({"status": "queued"})
# ✅ Right
@app.route("/notify")
def notify():
user_id = g.current_user["id"] # captura el valor antes de salir del contexto
def send(uid):
... # usa uid, no g
threading.Thread(target=send, args=(user_id,)).start()
return jsonify({"status": "queued"})
Captura los datos que necesitas del contexto mientras todavía estás en él y pásalos explícitamente al thread.
N° 178