Flask is the language’s middle child of web frameworks: not as opinionated as Django, not as new-wave as FastAPI. It’s been quietly powering production Python services since 2010 and isn’t going anywhere. The question in 2026 isn’t “is Flask still good?” — it’s “when is Flask still the right choice?”

This post is a practical Flask quickstart plus an honest look at when to pick it. By the end you’ll have a real Flask app structure and a clear sense of the alternative tradeoffs.

Why Flask still matters

  • Minimal core, huge ecosystem. Flask itself is small. The community has built extensions for everything (Flask-SQLAlchemy, Flask-Login, Flask-Migrate, Flask-Caching, Flask-Limiter, etc.).
  • Boring is a feature. Flask code from 2014 still runs. The core API has barely changed.
  • Massive talent pool. Almost every working Python developer knows it.
  • Excellent docs. The Flask docs are a model of how technical writing should be done.

That said: Flask is sync-first. Async support was bolted on later and is workable but not the design center. If async I/O is your primary workload, FastAPI is a better fit.

Install

mkdir flask-demo && cd flask-demo
python3 -m venv .venv
source .venv/bin/activate
pip install "flask>=3.0" python-dotenv

If uv is more your style, see Python Virtual Environments .

Hello, Flask

# app.py
from flask import Flask, jsonify

app = Flask(__name__)


@app.get("/")
def index():
    return jsonify(message="Hello, Flask!")

Run it:

flask --app app run --debug
# * Running on http://127.0.0.1:5000

--debug enables the auto-reloader and the interactive debugger. The latter is only for development — it executes arbitrary code if anyone reaches it.

Routes that go beyond hello

from flask import Flask, jsonify, request, abort

app = Flask(__name__)

# In-memory store for demo purposes
TASKS = {}
NEXT_ID = 1


@app.get("/tasks")
def list_tasks():
    return jsonify(tasks=list(TASKS.values()))


@app.post("/tasks")
def create_task():
    data = request.get_json(silent=True) or {}
    title = (data.get("title") or "").strip()
    if not title:
        abort(422, description="title is required")

    global NEXT_ID
    task = {"id": NEXT_ID, "title": title, "completed": False}
    TASKS[NEXT_ID] = task
    NEXT_ID += 1
    return jsonify(task), 201


@app.get("/tasks/<int:task_id>")
def get_task(task_id: int):
    task = TASKS.get(task_id)
    if task is None:
        abort(404)
    return jsonify(task)


@app.delete("/tasks/<int:task_id>")
def delete_task(task_id: int):
    if task_id not in TASKS:
        abort(404)
    del TASKS[task_id]
    return "", 204

Notice:

  • <int:task_id> parses and validates the path parameter as an integer.
  • request.get_json(silent=True) returns None on parse error instead of raising.
  • abort(422, description="...") returns a structured error.
  • Returning (body, status) from a handler sets the HTTP status code.

A real project structure

A single app.py is fine for demos. Real apps grow. Use the application factory pattern + blueprints:

flask-demo/
├── pyproject.toml
├── .flaskenv
├── wsgi.py
└── app/
    ├── __init__.py
    ├── config.py
    ├── extensions.py
    └── blueprints/
        ├── __init__.py
        ├── tasks/
        │   ├── __init__.py
        │   └── routes.py
        └── auth/
            ├── __init__.py
            └── routes.py

Configuration

# app/config.py
import os


class Config:
    SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-change-me")
    SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "sqlite:///app.db")
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    DEBUG = os.environ.get("FLASK_DEBUG", "0") == "1"

Application factory

# app/__init__.py
from flask import Flask

from app.config import Config
from app.extensions import db, migrate


def create_app(config_class: type[Config] = Config) -> Flask:
    app = Flask(__name__)
    app.config.from_object(config_class)

    # Initialize extensions
    db.init_app(app)
    migrate.init_app(app, db)

    # Register blueprints
    from app.blueprints.tasks.routes import tasks_bp
    from app.blueprints.auth.routes import auth_bp

    app.register_blueprint(tasks_bp, url_prefix="/tasks")
    app.register_blueprint(auth_bp, url_prefix="/auth")

    return app

Extensions live in their own module

# app/extensions.py
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate

db = SQLAlchemy()
migrate = Migrate()

This keeps extensions importable without circular imports.

A blueprint

# app/blueprints/tasks/routes.py
from flask import Blueprint, jsonify, request, abort

tasks_bp = Blueprint("tasks", __name__)


@tasks_bp.get("/")
def list_tasks():
    return jsonify(tasks=[])

# ... the rest of the task routes

WSGI entry point

# wsgi.py
from app import create_app

app = create_app()

.flaskenv

FLASK_APP=wsgi.py
FLASK_DEBUG=1

Now flask run works without setting environment variables manually.

Adding a database with Flask-SQLAlchemy

pip install flask-sqlalchemy flask-migrate

Define models:

# app/models.py
from datetime import datetime, timezone
from app.extensions import db


class Task(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(200), nullable=False)
    completed = db.Column(db.Boolean, nullable=False, default=False)
    created_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))

Initialize migrations:

flask db init
flask db migrate -m "create tasks table"
flask db upgrade

Use models in routes:

from app.models import Task
from app.extensions import db

@tasks_bp.get("/")
def list_tasks():
    tasks = Task.query.order_by(Task.created_at.desc()).all()
    return jsonify(tasks=[t.to_dict() for t in tasks])

@tasks_bp.post("/")
def create_task():
    data = request.get_json() or {}
    task = Task(title=data["title"])
    db.session.add(task)
    db.session.commit()
    return jsonify(task.to_dict()), 201

Error handling that doesn’t suck

# app/__init__.py (additions)
from flask import jsonify

@app.errorhandler(404)
def not_found(e):
    return jsonify(error="not found", path=request.path), 404

@app.errorhandler(422)
def unprocessable(e):
    return jsonify(error=str(e.description) or "unprocessable"), 422

@app.errorhandler(500)
def server_error(e):
    app.logger.exception("server error")
    return jsonify(error="internal server error"), 500

Centralized error handlers keep your responses consistent and let you log every 500 with full traceback.

Production deployment

For development, flask run is great. For production, use Gunicorn:

pip install gunicorn
gunicorn 'wsgi:app' --workers 4 --bind 0.0.0.0:8000 --timeout 30

Then put Nginx in front for TLS and static files. The exact recipe is the same as in Deploying Django to Production — Gunicorn + Nginx + systemd works for any WSGI app.

Useful Flask extensions

  • Flask-SQLAlchemy — ORM integration.
  • Flask-Migrate — Alembic-based migrations.
  • Flask-Login — session-based auth.
  • Flask-JWT-Extended — JWT auth.
  • Flask-CORS — for SPA frontends on a different origin.
  • Flask-Limiter — rate limiting.
  • Flask-Caching — caching (Redis, Memcached, file).
  • Flask-Smorest / APIFlask — OpenAPI generation if you want Swagger-style docs.

If you find yourself adding 10+ extensions, you’re rebuilding Django. Consider whether Django would have served you better.

Honest 2026 take: when to pick Flask

Pick Flask when:

  • You want something between “raw stdlib” and “full Django” — minimal core, sane defaults, no async pressure.
  • Your team already knows it well — friction matters.
  • You’re maintaining or extending an existing Flask app.
  • You like assembling exactly the stack you want, no more, no less.

Pick FastAPI instead when:

  • Your workload is API-heavy and async would meaningfully help (lots of upstream calls, websockets, streaming).
  • You want type-driven validation and auto-generated OpenAPI docs out of the box.
  • Type hints are how you and your team think about code.

Pick Django instead when:

  • You need an admin, auth, ORM, templates, the works.
  • You’re building a product (UI + auth + dashboards), not just an API.
  • You want maximum convention so the team doesn’t argue about structure.

For a brand-new API project today I’d reach for FastAPI. For a brand-new full-stack product I’d reach for Django. Flask sits in a smaller niche than it used to — but for the right project, it’s still the cleanest choice.

Conclusion

Flask is mature, stable, and deliberately small. Use the application factory pattern, organize with blueprints, lean on the extensions ecosystem, and you’ll have an app that scales reasonably and ages well. The framework gets out of your way — that’s both its weakness and its strength.

If you’re choosing between Python web frameworks, see Django vs FastAPI: Which One Should You Pick in 2026? — the same comparison logic applies to picking Flask.

Happy hacking!


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .