Flask para Python: crea tu primera aplicación web
Flask nació de una broma del Día de los Inocentes. En 2010, Armin Ronacher publicó un "framework web ultramínimo" como chiste en su blog. Era tan compacto que cabía en un solo archivo. La broma tuvo tanto éxito que Armin lo convirtió en un proyecto serio, y hoy Flask es uno de los tres frameworks web Python más usados del mundo. La lección: a veces las mejores herramientas son las que empiezan preguntando "¿y si quitamos todo lo que no es imprescindible?"
Esta lección asume que sabes Python (funciones, clases, decoradores). No requiere experiencia previa con web. Al final tendrás una aplicación Flask funcional con rutas, plantillas, formularios y una estructura que escala.
¿Qué es Flask y por qué es diferente?
Flask se define a sí mismo como un "microframework". El prefijo micro no significa que sea menos potente — significa que toma las mínimas decisiones posibles por ti. Flask incluye dos cosas: un sistema de routing para mapear URLs a funciones Python, y el motor de templates Jinja2. Todo lo demás (base de datos, autenticación, validación de formularios, caché, tareas en segundo plano) es opcional y elegible por ti.
La alternativa es Django, que incluye todo eso desde el primer día. La diferencia filosófica es real: Django dice "esta es la forma correcta de hacer las cosas, síguenos". Flask dice "aquí tienes las primitivas, construye lo que necesitas". Ninguno es mejor; son compromisos distintos.
# ── La aplicación Flask más pequeña posible ──
from flask import Flask
app = Flask(__name__) # __name__ dice a Flask dónde buscar los templates
@app.route('/')
def inicio():
return 'Hola, mundo'
# Eso es todo. Un archivo, 6 líneas, un servidor web funcional.
# Para arrancar:
# flask --app app run
# flask --app app run --debug ← con recarga automática en cambios
Lo que hace Flask con esas 6 líneas es más de lo que parece. Cuando llega una petición a /, Flask la recibe del servidor WSGI, crea un contexto de request, busca qué función está registrada para esa URL, la ejecuta, convierte el string devuelto en una respuesta HTTP con sus cabeceras, y la envía de vuelta al cliente. El decorador @app.route('/') es el mecanismo que registra esa asociación URL → función.
# ── Qué hay dentro de Flask cuando lo importas ──
from flask import (
Flask, # La aplicación
request, # La petición actual (URL, headers, body, cookies)
session, # Sesión de usuario (cookie firmada)
g, # Datos por request (conexión BD, usuario cargado)
render_template, # Renderizar un archivo .html con Jinja2
redirect, # Devolver una redirección HTTP 301/302
url_for, # Generar URLs a partir de nombre de función
jsonify, # Devolver JSON con Content-Type correcto
abort, # Lanzar error HTTP (404, 403, 500...)
flash, # Mensajes de una sola vez (éxito, error)
make_response, # Crear respuesta HTTP personalizada
send_file, # Servir un archivo
current_app, # La app actual (útil en extensiones y blueprints)
)
# Todo eso es el núcleo de Flask. No hay magia adicional.
# Cada uno de esos objetos es thread-local (o context-local en Flask 2.x):
# en cada request tienes tu propio 'request', 'g' y 'session' independientes.
request, session y g existen solo durante ese ciclo — cuando la respuesta sale, desaparecen. La parte inferior muestra los cuatro objetos de contexto que usarás en casi cada view function. Infografía: Ciberaula.Instalación y primer servidor
Flask se instala en un entorno virtual, como cualquier paquete Python. La instalación trae consigo Werkzeug (servidor WSGI de desarrollo y utilidades HTTP), Jinja2 (templates), Click (CLI), MarkupSafe, y unas pocas dependencias más. Todo el core de Flask pesa menos de 1 MB.
# ── Instalación en entorno virtual ──
python -m venv venv
source venv/bin/activate # Linux/Mac
# venv\Scripts\activate # Windows
pip install flask
# Verificar:
python -c "import flask; print(flask.__version__)"
# 3.x.x
# ── Estructura mínima de un proyecto Flask ──
# mi_proyecto/
# ├── app.py ← o __init__.py si usas paquete
# ├── templates/ ← Jinja2 busca aquí por defecto
# │ └── index.html
# ├── static/ ← archivos estáticos (CSS, JS, imágenes)
# │ └── style.css
# └── requirements.txt
# app.py
from flask import Flask, render_template
app = Flask(__name__)
app.config['SECRET_KEY'] = 'cambia-esto-en-produccion'
@app.route('/')
def inicio():
return render_template('index.html', titulo='Mi primera app Flask')
# templates/index.html
#
# {{ titulo }}
# {{ titulo }}
# ── Arrancar el servidor de desarrollo ──
# Opción 1: variable de entorno
export FLASK_APP=app
export FLASK_DEBUG=1
flask run
# Opción 2 (Flask 2.x+): directamente
flask --app app run --debug
# Opción 3: desde el código (solo para desarrollo)
if __name__ == '__main__':
app.run(debug=True)
# El servidor escucha en http://127.0.0.1:5000
# --debug activa: recarga automática + debugger interactivo en el navegador
# NUNCA usar debug=True en producción — permite ejecutar código arbitrario
El sistema de rutas: @app.route
El routing es el corazón de Flask. Cada URL de tu aplicación se mapea a una función Python mediante el decorador @app.route(). La función recibe la petición (implícitamente, a través del objeto global request) y devuelve la respuesta.
from flask import Flask, request, jsonify, abort, redirect, url_for
app = Flask(__name__)
# ── Rutas básicas ──
@app.route('/')
def inicio():
return 'Bienvenido
'
@app.route('/sobre-nosotros')
def sobre():
return 'Página sobre nosotros'
# ── Parámetros de ruta ──
@app.route('/usuario/')
def perfil_usuario(id: int):
# convierte automáticamente el segmento a entero
# Si no es entero, Flask devuelve 404
return f'Usuario {id}'
@app.route('/articulo/')
def articulo(slug: str):
# es string por defecto (no acepta barras /)
return f'Artículo: {slug}'
@app.route('/docs/')
def documentacion(ruta: str):
# acepta barras: /docs/guia/instalacion/windows
return f'Docs: {ruta}'
# Convertidores disponibles: string (default), int, float, path, uuid
# ── Métodos HTTP ──
@app.route('/api/usuarios', methods=['GET'])
def listar_usuarios():
usuarios = [{'id': 1, 'nombre': 'Ana'}, {'id': 2, 'nombre': 'Luis'}]
return jsonify(usuarios)
@app.route('/api/usuarios', methods=['POST'])
def crear_usuario():
datos = request.get_json() # leer body JSON
if not datos or 'nombre' not in datos:
abort(400) # Bad Request
nuevo = {'id': 3, 'nombre': datos['nombre']}
return jsonify(nuevo), 201 # 201 Created
# Una misma ruta para GET y POST:
@app.route('/contacto', methods=['GET', 'POST'])
def contacto():
if request.method == 'POST':
# procesar formulario
return redirect(url_for('gracias'))
return render_template('contacto.html')
# ── url_for: nunca construyas URLs a mano ──
# url_for genera URLs a partir del nombre de la función
# Ventaja: si cambias la URL, no tienes que actualizar todos los enlaces
from flask import url_for
with app.test_request_context():
print(url_for('inicio')) # '/'
print(url_for('perfil_usuario', id=5)) # '/usuario/5'
print(url_for('articulo', slug='flask-tutorial'))
# '/articulo/flask-tutorial'
print(url_for('static', filename='style.css'))
# '/static/style.css'
# Con url externo (para emails, APIs):
url_for('inicio', _external=True)
# 'http://localhost:5000/'
# ── Manejadores de error ──
@app.errorhandler(404)
def pagina_no_encontrada(error):
return render_template('404.html'), 404
@app.errorhandler(500)
def error_interno(error):
return render_template('500.html'), 500
@app.errorhandler(403)
def acceso_denegado(error):
return jsonify({'error': 'Acceso denegado'}), 403
@app.route('/usuarios') es literalmente "en esta intersección, el tráfico hacia /usuarios va a esta función". Sin rutas definidas, la petición no tiene destino y Flask devuelve 404. Fuente: Pexels (licencia libre).Plantillas Jinja2
Los templates resuelven un problema concreto: generar HTML dinámico sin escribir código Python que construya strings HTML. Jinja2 separa la presentación de la lógica. Flask busca las plantillas en el directorio templates/ por defecto.
# ── render_template: la función central ──
from flask import render_template
@app.route('/cursos')
def lista_cursos():
cursos = [
{'nombre': 'Python', 'nivel': 'Todos', 'alumnos': 1240},
{'nombre': 'Django', 'nivel': 'Intermedio', 'alumnos': 890},
{'nombre': 'Flask', 'nivel': 'Intermedio', 'alumnos': 670},
]
return render_template(
'cursos.html',
cursos=cursos,
titulo='Cursos disponibles'
)
<!-- templates/base.html — plantilla base con bloques reutilizables -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>{% block titulo %}Mi App{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<nav>
<a href="{{ url_for('inicio') }}">Inicio</a>
<a href="{{ url_for('lista_cursos') }}">Cursos</a>
</nav>
<main>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for categoria, mensaje in messages %}
<div class="alerta alerta-{{ categoria }}">{{ mensaje }}</div>
{% endfor %}
{% endwith %}
{% block contenido %}{% endblock %} {# cada página rellena este bloque #}
</main>
<footer>© 2026 Mi App</footer>
</body>
</html>
<!-- templates/cursos.html — hereda de base.html -->
{% extends 'base.html' %}
{% block titulo %}{{ titulo }} — Mi App{% endblock %}
{% block contenido %}
<h1>{{ titulo }}</h1>
<table>
<thead>
<tr><th>Curso</th><th>Nivel</th><th>Alumnos</th></tr>
</thead>
<tbody>
{% for curso in cursos %}
<tr>
<td>{{ curso.nombre }}</td>
<td>{{ curso.nivel }}</td>
<td>{{ curso.alumnos | int | format_number }}</td>
</tr>
{% else %}
<tr><td colspan="3">No hay cursos disponibles</td></tr>
{% endfor %}
</tbody>
</table>
{% if cursos|length > 5 %}
<p>Mostrando {{ cursos|length }} cursos</p>
{% endif %}
{% endblock %}
# ── Filtros y variables globales Jinja2 en Flask ──
# Filtros incorporados más usados:
# {{ texto | upper }} → MAYÚSCULAS
# {{ texto | lower }} → minúsculas
# {{ texto | title }} → Título De Cada Palabra
# {{ texto | truncate(50) }} → primeros 50 chars + '...'
# {{ valor | round(2) }} → redondear
# {{ lista | length }} → longitud
# {{ html | safe }} → no escapar HTML (¡solo contenido confiable!)
# {{ valor | default('—') }} → valor si es None/undefined
# Filtros personalizados:
import datetime
@app.template_filter('fecha_es')
def fecha_espanol(value: datetime.date) -> str:
meses = ['ene','feb','mar','abr','may','jun',
'jul','ago','sep','oct','nov','dic']
return f"{value.day} {meses[value.month-1]} {value.year}"
# En template: {{ articulo.fecha | fecha_es }} → "9 mar 2026"
# Variables globales disponibles en TODOS los templates:
# request → la petición actual
# session → sesión del usuario
# g → datos del contexto de request
# config → app.config
# url_for() → generar URLs
# get_flashed_messages() → mensajes flash
# Añadir tus propias variables globales:
@app.context_processor
def variables_globales():
return {
'año_actual': datetime.date.today().year,
'nombre_app': 'Mi Plataforma',
}
# Ahora {{ año_actual }} y {{ nombre_app }} están disponibles en todos los templates
Formularios y peticiones POST
Los formularios HTML envían datos al servidor mediante POST. Flask los recibe en request.form. Para protección CSRF (Cross-Site Request Forgery), lo correcto es usar Flask-WTF, pero para entender el mecanismo primero vemos el flujo sin extensiones.
from flask import Flask, request, redirect, url_for, flash, render_template, session
app = Flask(__name__)
app.secret_key = 'clave-secreta-aqui' # necesaria para session y flash
# ── Patrón POST / Redirect / GET ──
# El patrón estándar para formularios: evita reenvíos accidentales al recargar
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form.get('username', '').strip()
password = request.form.get('password', '')
# Validación manual (en producción usa Flask-WTF + Flask-Login)
if not username or not password:
flash('Usuario y contraseña son obligatorios', 'error')
return redirect(url_for('login'))
if username == 'admin' and password == 'secreto':
session['usuario'] = username
flash('Bienvenido, ' + username, 'success')
return redirect(url_for('dashboard')) # ← Redirect después de POST
else:
flash('Credenciales incorrectas', 'error')
return redirect(url_for('login'))
# GET: mostrar el formulario
return render_template('login.html')
@app.route('/dashboard')
def dashboard():
if 'usuario' not in session:
return redirect(url_for('login'))
return render_template('dashboard.html', usuario=session['usuario'])
@app.route('/logout')
def logout():
session.pop('usuario', None)
flash('Has cerrado sesión', 'info')
return redirect(url_for('login'))
<!-- templates/login.html -->
{% extends 'base.html' %}
{% block contenido %}
<form method="POST" action="{{ url_for('login') }}">
{# En producción: incluir token CSRF con Flask-WTF #}
{# {{ form.hidden_tag() }} #}
<div>
<label for="username">Usuario</label>
<input type="text" id="username" name="username"
value="{{ request.form.get('username', '') }}"
autocomplete="username">
</div>
<div>
<label for="password">Contraseña</label>
<input type="password" id="password" name="password"
autocomplete="current-password">
</div>
<button type="submit">Entrar</button>
</form>
{% endblock %}
# ── Subida de archivos ──
import os
from werkzeug.utils import secure_filename
UPLOAD_FOLDER = '/tmp/uploads'
ALLOWED_EXTENSIONS = {'png', 'jpg', 'pdf'}
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16 MB máximo
def extension_permitida(filename: str) -> bool:
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@app.route('/subir', methods=['GET', 'POST'])
def subir_archivo():
if request.method == 'POST':
archivo = request.files.get('archivo')
if not archivo or archivo.filename == '':
flash('No se seleccionó archivo', 'error')
return redirect(request.url)
if not extension_permitida(archivo.filename):
flash('Tipo de archivo no permitido', 'error')
return redirect(request.url)
nombre_seguro = secure_filename(archivo.filename)
ruta = os.path.join(app.config['UPLOAD_FOLDER'], nombre_seguro)
archivo.save(ruta)
flash(f'Archivo {nombre_seguro} subido correctamente', 'success')
return redirect(url_for('subir_archivo'))
return render_template('subir.html')
# ── API JSON (sin formularios HTML) ──
@app.route('/api/cursos', methods=['POST'])
def crear_curso_api():
datos = request.get_json(silent=True) # silent=True → None si no es JSON
if not datos:
return jsonify({'error': 'Se esperaba JSON'}), 400
nombre = datos.get('nombre', '').strip()
if not nombre:
return jsonify({'error': 'El campo nombre es obligatorio'}), 422
# ... guardar en BD
return jsonify({'id': 1, 'nombre': nombre}), 201
Blueprints: organizar aplicaciones medianas
Cuando la aplicación crece, tener todo en un solo archivo app.py se vuelve insostenible. Los Blueprints son el mecanismo de Flask para dividir la aplicación en módulos independientes. Cada Blueprint tiene sus propias rutas, templates y archivos estáticos.
# ── Estructura de proyecto con Blueprints ──
# mi_app/
# ├── __init__.py ← App factory: create_app()
# ├── config.py ← Configuración por entorno
# ├── auth/
# │ ├── __init__.py
# │ ├── routes.py ← rutas del Blueprint auth
# │ └── forms.py
# ├── cursos/
# │ ├── __init__.py
# │ ├── routes.py ← rutas del Blueprint cursos
# │ └── models.py
# ├── templates/
# │ ├── base.html
# │ ├── auth/
# │ │ └── login.html
# │ └── cursos/
# │ └── lista.html
# └── static/
# ── auth/routes.py — definir el Blueprint ──
from flask import Blueprint, render_template, redirect, url_for, flash, session, request
bp = Blueprint('auth', __name__, url_prefix='/auth')
@bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
# ... lógica de autenticación
session['usuario'] = request.form['username']
return redirect(url_for('cursos.lista')) # ← 'blueprint.funcion'
return render_template('auth/login.html')
@bp.route('/logout')
def logout():
session.clear()
return redirect(url_for('auth.login')) # ← nombre completo de la ruta
# ── cursos/routes.py ──
from flask import Blueprint, render_template
bp = Blueprint('cursos', __name__, url_prefix='/cursos')
@bp.route('/')
def lista():
return render_template('cursos/lista.html')
@bp.route('/')
def detalle(id: int):
return render_template('cursos/detalle.html', curso_id=id)
# ── __init__.py — App Factory ──
from flask import Flask
def create_app(config_object='mi_app.config.Config'):
app = Flask(__name__)
app.config.from_object(config_object)
# Inicializar extensiones (SQLAlchemy, etc.)
# db.init_app(app)
# Registrar Blueprints
from mi_app.auth.routes import bp as auth_bp
from mi_app.cursos.routes import bp as cursos_bp
app.register_blueprint(auth_bp)
app.register_blueprint(cursos_bp)
return app
# ── config.py — configuración por entorno ──
import os
class Config:
"""Configuración base."""
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-key-insegura')
SQLALCHEMY_TRACK_MODIFICATIONS = False
class DevelopmentConfig(Config):
DEBUG = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///dev.db'
class ProductionConfig(Config):
DEBUG = False
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', '')
# En producción: SECRET_KEY SIEMPRE desde variable de entorno
SECRET_KEY = os.environ['SECRET_KEY'] # falla si no está definida — a propósito
class TestingConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' # BD en memoria para tests
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'testing': TestingConfig,
'default': DevelopmentConfig,
}
# Uso:
# app = create_app(config['production'])
Flask en producción
El servidor de desarrollo de Flask (flask run) sirve para lo que dice: desarrollar. Maneja una petición a la vez, tiene el debugger interactivo activado (lo que permite ejecutar código Python desde el navegador si hay un error), y no está optimizado para carga. Para producción se necesita un servidor WSGI real.
# ── Gunicorn: el servidor WSGI más común para Flask ──
pip install gunicorn
# Arrancar con 4 workers (procesos independientes)
# Regla empírica: workers = (2 × núcleos CPU) + 1
gunicorn -w 4 'mi_app:create_app()'
# Con opciones de producción:
gunicorn \
--workers 4 \
--bind 0.0.0.0:8000 \
--timeout 120 \
--access-logfile /var/log/gunicorn/access.log \
--error-logfile /var/log/gunicorn/error.log \
'mi_app:create_app()'
# Verificar que funciona:
curl http://localhost:8000/
# ── Nginx como proxy reverso (configuración básica) ──
# /etc/nginx/sites-available/mi_app
server {
listen 80;
server_name miapp.com www.miapp.com;
# Archivos estáticos servidos directamente por Nginx (más rápido)
location /static {
alias /var/www/mi_app/static;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Todo lo demás va a Gunicorn
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# ── wsgi.py — punto de entrada para producción ──
from mi_app import create_app
import os
app = create_app(os.environ.get('FLASK_ENV', 'production'))
if __name__ == '__main__':
# Solo para desarrollo local
app.run()
# Para Gunicorn: gunicorn 'wsgi:app'
# Para uWSGI: uwsgi --http :8000 --module wsgi:app
# ── Lista de verificación antes de desplegar ──
# ✅ DEBUG = False en producción
# ✅ SECRET_KEY desde variable de entorno (nunca en el código)
# ✅ DATABASE_URL desde variable de entorno
# ✅ HTTPS configurado en Nginx
# ✅ app.run() no aparece en el código de producción
# ✅ requirements.txt actualizado (pip freeze > requirements.txt)
# ✅ Variables de entorno en .env (no en el repositorio — añadir a .gitignore)
# ✅ Logs configurados (acceso + errores)
# ✅ Gunicorn con al menos 2 workers
❓ Preguntas frecuentes
❓ Preguntas frecuentes sobre Flask para Python: crea tu primera aplicación web
Las dudas más comunes respondidas de forma clara y directa.
🎯 ¿Quieres certificarte en Python?
Ciberaula ofrece cursos bonificados de Python con tutor personal, desde nivel básico hasta desarrollo web con Flask y Django. Formación subvencionada por FUNDAE.
Ver cursos de Python bonificados →💬 Foro de discusión
¿Tienes dudas sobre Flask para Python: crea tu primera aplicación web? Comparte tu pregunta con la comunidad.
Todavía no hay mensajes. ¡Sé el primero en participar!