Flask para Python: crea tu primera aplicación web

📅 Actualizado en marzo 2026 📊 Nivel: Intermedio ⏱️ 20 min de lectura

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.

Medallón de Heráclito de Éfeso, filósofo griego conocido por la doctrina del flujo universal y el logos
Πάντα ῥεῖ καὶ οὐδὲν μένει
«Todo fluye y nada permanece»
Heráclito de Éfeso · Filósofo griego · ~535 a.C. – ~475 a.C.
Heráclito enseñó que la realidad no es un conjunto de cosas fijas sino un flujo perpetuo: el fuego que todo lo transforma, el río en el que no puedes bañarte dos veces. HTTP es la versión digital de ese río. Cada petición es un evento nuevo que emerge de la nada, recorre el stack de la aplicación, produce una respuesta, y desaparece. El servidor no recuerda la petición anterior; el protocolo es sin estado por diseño. Flask abraza esa naturaleza efímera: cada request trae su propio contexto (request, g, session), lo usa durante el tiempo que tarda en producir la respuesta, y lo descarta. El estado que persiste —la sesión del usuario, los datos en base de datos— es algo que tú decides guardar explícitamente. Heráclito habría apreciado esa honestidad: no fingir que las cosas son estables cuando el flujo es la naturaleza de las cosas.

¿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.
Diagrama del ciclo completo de una petición HTTP en Flask: navegador envía request, pasa por WSGI server, Flask App con URL Router y view functions, Jinja2 renderiza el template, y la respuesta HTML vuelve al navegador
El ciclo completo de una petición en Flask. Los objetos 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
Bartender mujer de blanco sirviendo con precisión una copa de vino tinto en un bar elegante con estanterías de botellas al fondo
Flask sirve peticiones HTTP exactamente como un bartender sirve bebidas: recibe el pedido (request), prepara la respuesta con los ingredientes correctos (datos + template), y la entrega al cliente. La palabra flask en inglés designa el matraz o la petaca — el recipiente que contiene y sirve. Ninguna petición se mezcla con otra; cada una recibe su contexto limpio. Fuente: Pexels (licencia libre).

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
Vista aérea de una gran intersección urbana con cruces de calles, señalización amarilla horizontal pintada en el asfalto y vehículos desde arriba
El sistema de rutas de Flask funciona como una intersección urbana: cada URL es una calle con su dirección definida, y el router decide hacia dónde dirigir cada petición entrante. @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>&copy; 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
Ficha de referencia rápida de Flask para Python con seis secciones: rutas y métodos HTTP, Jinja2 templates, Blueprints, App Factory, configuración con extensiones comunes, y configuración de producción con Gunicorn y Nginx
Referencia rápida de Flask: los seis bloques que necesitas dominar para construir aplicaciones completas. Las columnas de configuración y producción son las que más se olvidan — y las que más importan cuando la app sale del entorno local. Referencia rápida: Ciberaula.

❓ 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.

La pregunta correcta no es "¿cuál es mejor?" sino "¿cuánto control necesito?". Django es un framework full-stack con ORM, panel de administración, autenticación, formularios y migraciones incluidos. Toma decisiones por ti y las impone. Flask no incluye nada de eso: tú decides qué ORM usar (SQLAlchemy, Peewee, Tortoise), qué sistema de autenticación, qué validación de formularios. Esa libertad tiene un precio: más decisiones, más configuración inicial. La regla práctica: si estás construyendo una aplicación CRUD estándar con usuarios, permisos y panel de administración, Django te va a ahorrar semanas. Si estás construyendo una API REST, un microservicio, un prototipo, o una aplicación con requisitos muy específicos que no encajan con las convenciones de Django, Flask te va a dar menos fricción. Flask también es mejor para aprender HTTP y el ciclo request/response porque no hay magia oculta: ves exactamente qué pasa en cada paso.
Sí, con las condiciones correctas. El servidor de desarrollo que Flask incluye (el que arranca con flask run) no es apto para producción: es monohilo, no tiene manejo de errores robusto, y no escala. Para producción se usa Flask detrás de un servidor WSGI como Gunicorn (el más común) o uWSGI, que gestiona los procesos y las conexiones concurrentes. Por encima del servidor WSGI suele ponerse un proxy reverso como Nginx que gestiona SSL, archivos estáticos y balanceo de carga. Con esa configuración (Nginx → Gunicorn → Flask), Flask sirve perfectamente aplicaciones en producción. Pinterest, LinkedIn y Netflix han usado Flask en partes de su stack. Lo que no es Flask es un framework pensado para escala horizontal automática: para eso hay arquitecturas de microservicios donde cada servicio Flask es pequeño e independiente, y se escalan individualmente.
Jinja2 es un motor de plantillas para Python, escrito por Armin Ronacher, el mismo autor de Flask. Las plantillas resuelven el problema de mezclar HTML con datos dinámicos sin ensuciar el código Python con strings de HTML. Jinja2 tiene una sintaxis clara: {{ variable }} para insertar valores, {% if %}/{% for %} para lógica, {% extends %}/{% block %} para herencia de plantillas (evita duplicar el mismo header y footer en cada página). La herencia de plantillas es la característica más potente: defines un base.html con la estructura común y cada página concreta solo sobreescribe los bloques que cambian. Flask usa Jinja2 por la integración natural (mismo autor, misma filosofía) y porque Jinja2 tiene auto-escaping activado por defecto, lo que protege automáticamente contra XSS: si un usuario introduce en su nombre, Jinja2 lo convierte en texto literal en lugar de HTML ejecutable.
Flask no incluye autenticación, pero Flask-Login es la extensión estándar para gestionar sesiones de usuario. Flask-Login proporciona el decorador @login_required para proteger rutas, el objeto current_user disponible en templates y vistas, y las funciones login_user()/logout_user() para crear y destruir sesiones. La sesión en Flask se gestiona con una cookie firmada criptográficamente con SECRET_KEY: los datos se almacenan en el cliente pero solo Flask puede verificar que no han sido modificados. Para OAuth2 (login con Google, GitHub) existe Flask-OAuthlib o la librería Authlib. Para APIs REST sin sesiones, lo habitual es JWT (JSON Web Tokens) con Flask-JWT-Extended: el cliente recibe un token al autenticarse y lo envía en cada request en la cabecera Authorization. La diferencia clave: las sesiones son para aplicaciones web con navegador, los JWT son para APIs consumidas por móviles o frontends JavaScript.
FastAPI es el nuevo estándar para APIs Python modernas: async/await nativo, validación automática con Pydantic, documentación OpenAPI generada automáticamente, y tipado estático que el framework usa en tiempo de ejecución. Si estás construyendo una API nueva en 2026, FastAPI probablemente sea la elección más productiva. Dicho esto, Flask sigue siendo muy relevante: tiene un ecosistema de extensiones más maduro (Flask-SQLAlchemy, Flask-Login, Flask-Admin), es síncrono y más fácil de depurar para desarrolladores que no dominan async/await, y si la aplicación tiene tanto lógica web (HTML) como API, Flask lo gestiona naturalmente en la misma app mientras que FastAPI está más orientado a APIs puras. La recomendación práctica: aprende Flask para entender HTTP, request/response, y el ciclo completo de una aplicación web. Después aprende FastAPI para APIs de alto rendimiento. Los conceptos de routing, templates, autenticación y organización de código son transferibles.

🎯 ¿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 →
Valora este artículo

💬 Foro de discusión

¿Tienes dudas sobre Flask para Python: crea tu primera aplicación web? Comparte tu pregunta con la comunidad.

¿Tienes cuenta? o comenta como invitado ↓

Todavía no hay mensajes. ¡Sé el primero en participar!