You’re staring at a new Python project. Three frameworks sit on the table. Django is the truck with everything bolted on. Flask is the frame with optional wheels. FastAPI is the shiny sports car that does 0-60 and auto-generates its own manual. Which one doesn’t make your 2 AM self regret everything?
Here’s the thing: they’re not competing in the same lane. Django wins if you want to ship a full-stack app in a weekend. Flask wins if you’ve got specific opinions about your stack. FastAPI wins if you’re tired of writing validation code and want async I/O without the circus. Let’s break it down.
The Minimal Hello World (or: “Why Does Django Need a Database?”)
Flask
from flask import Flask
app = Flask(__name__)
@app.route('/')def hello(): return {'message': 'hello world'}
if __name__ == '__main__': app.run(debug=True)You run it, you get a server. That’s it. Flask doesn’t assume anything. No database. No admin panel. No migrations. Just you, a decorator, and whatever you decide to bolt on.
FastAPI
from fastapi import FastAPI
app = FastAPI()
@app.get('/')def hello(): return {'message': 'hello world'}
# Run with: uvicorn app:app --reloadFastAPI is the same vibe as Flask—minimal by default—but with async built in and OpenAPI docs auto-generated at /docs. You don’t have to ask for it; it just exists.
Django
django-admin startproject mysitepython manage.py runserverDjango creates a project, a database (SQLite by default), and a migrations folder. Before you write a single line of business logic, Django has already created infrastructure you might not use. That’s not laziness; that’s Django saying “most web apps need these things, let me get them ready.” Your 2 AM self either loves it or hates it, depending on whether you want those things.
Routing and Path Parameters
Flask
from flask import Flask, request
app = Flask(__name__)
@app.route('/users/<int:user_id>')def get_user(user_id): return {'user_id': user_id}
@app.route('/posts/<slug>', methods=['GET', 'POST'])def post_detail(slug): if request.method == 'POST': # handle creation pass return {'slug': slug}Flask uses angle brackets for path params and decorators for HTTP methods. It’s straightforward; nothing fancy.
FastAPI
from fastapi import FastAPI
app = FastAPI()
@app.get('/users/{user_id}')def get_user(user_id: int): return {'user_id': user_id}
@app.post('/users/')def create_user(name: str): return {'created': name}
@app.get('/posts/{slug}')def post_detail(slug: str): return {'slug': slug}FastAPI uses curly braces and type hints. The type hint (int, str) tells FastAPI to validate and coerce the path param automatically. No extra validation layer needed. You define the type, FastAPI handles it.
Django
from django.urls import pathfrom . import views
urlpatterns = [ path('users/<int:user_id>/', views.get_user), path('posts/<slug:slug>/', views.post_detail),]
# In views.pyfrom django.http import JsonResponse
def get_user(request, user_id): return JsonResponse({'user_id': user_id})
def post_detail(request, slug): return JsonResponse({'slug': slug})Django separates routes (urls.py) from handlers (views.py). Django calls it a “view,” Flask and FastAPI call it a function. It’s a vocabulary thing.
Request Validation (The Part That Saves You Hours)
This is where the philosophies really split.
Flask (with Marshmallow)
from flask import Flask, requestfrom marshmallow import Schema, fields, ValidationError
app = Flask(__name__)
class UserSchema(Schema): email = fields.Email(required=True) age = fields.Integer(required=True) bio = fields.String()
schema = UserSchema()
@app.post('/users/')def create_user(): try: data = schema.load(request.json) except ValidationError as err: return {'errors': err.messages}, 400
# data is clean, proceed return {'created': data['email']}, 201You write a Schema, you validate manually in each route. It’s explicit. It’s flexible. It’s also boilerplate-heavy if you’ve got 50 routes.
FastAPI (with Pydantic)
from fastapi import FastAPIfrom pydantic import BaseModel, EmailStr
app = FastAPI()
class UserCreate(BaseModel): email: EmailStr age: int bio: str | None = None
@app.post('/users/')def create_user(user: UserCreate): # FastAPI already validated. user is clean. return {'created': user.email}You define a Pydantic model, FastAPI validates the JSON body against it automatically, and returns a 422 error if validation fails. No try/except. No manual .load() call. The function signature is the contract.
FastAPI also generates JSON Schema automatically and shows it in the interactive docs at /docs.
Django (with Django Forms or DRF)
from django import formsfrom django.views import Viewfrom django.http import JsonResponse
class UserForm(forms.Form): email = forms.EmailField() age = forms.IntegerField() bio = forms.CharField(required=False)
class CreateUserView(View): def post(self, request): form = UserForm(request.POST) if form.is_valid(): # form.cleaned_data is clean return JsonResponse({'created': form.cleaned_data['email']}) return JsonResponse({'errors': form.errors}, status=400)Django Forms are built for HTML rendering, so they feel a little odd in an API context. Most people reach for Django REST Framework instead, which gives you Serializers (similar vibe to Marshmallow, but integrated with Django models). Django doesn’t auto-generate OpenAPI docs; you need drf-spectacular for that.
The Database Story
Flask (you pick)
Flask has zero opinions. You can use:
- SQLAlchemy (industry standard, async support via
async_sessionmaker) - SQLModel (FastAPI’s recommended choice, but works fine with Flask)
- Peewee (simpler ORM, great for small projects)
- Nothing (just raw SQL with
sqlite3orpsycopg2)
from flask import Flaskfrom flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///users.db'db = SQLAlchemy(app)
class User(db.Model): id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String, unique=True)
@app.route('/users/<int:user_id>')def get_user(user_id): user = User.query.get(user_id) return {'email': user.email}It’s simple, but you manage migrations yourself and handle async elsewhere.
Django (batteries included)
Django’s ORM is built in. Migrations are handled for you. Admin panel is auto-generated from your models.
from django.db import models
class User(models.Model): email = models.EmailField(unique=True) created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): return self.email
# In views.pyfrom django.http import JsonResponse
def get_user(request, user_id): user = User.objects.get(id=user_id) return JsonResponse({'email': user.email})
# Then:# python manage.py makemigrations# python manage.py migrate# python manage.py createsuperuser# Visit /admin/ and manage users from the UIDjango’s ORM is tightly coupled to the framework. If you want async ORM access, Django 3.1+ added async views, but it’s still not as smooth as FastAPI+SQLAlchemy. Django migrations are fantastic—they version your schema, they’re reversible, they’re safe.
FastAPI (SQLModel or SQLAlchemy)
from fastapi import FastAPIfrom sqlmodel import Field, Session, SQLModel, create_engine
app = FastAPI()
class User(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) email: str age: int
DATABASE_URL = "sqlite:///database.db"engine = create_engine(DATABASE_URL)
User.metadata.create_all(engine)
@app.get('/users/{user_id}')def get_user(user_id: int): with Session(engine) as session: user = session.get(User, user_id) return userSQLModel marries Pydantic models and SQLAlchemy ORM, so your database model and your API response schema are the same class. Nice. Async is built in via async_sessionmaker and FastAPI’s native async support.
The catch: SQLModel is maintained by the FastAPI author but not as battle-tested as pure SQLAlchemy. If you want maximum stability, use SQLAlchemy directly with FastAPI.
Async Support (The 2026 Scorecard)
Flask
Flask has async/await support in routes (since Flask 2.0), but it’s half-baked. The async context doesn’t propagate well to database operations, background tasks get messy, and you’ll find yourself swimming upstream if your app’s async needs grow beyond “one or two slow I/O calls.”
from flask import Flaskimport asyncio
app = Flask(__name__)
@app.route('/slow/')async def slow_endpoint(): await asyncio.sleep(1) # works fine return {'done': True}It runs, but Flask itself is WSGI (a synchronous protocol). You’re threading async into a sync framework. Works for light async, breaks if you lean on it.
Django
Django added async view support in 3.1 and keeps improving it, but it’s similar to Flask—you’re async-wrapping a fundamentally sync framework. Async views work. Async ORM access is improving. But you’re not getting the clean async-first experience.
FastAPI
FastAPI is built on ASGI (async protocol) from the ground up. Every route is async-capable. You get concurrency without threads. If your app calls three APIs in sequence, FastAPI will handle hundreds of concurrent requests without breaking a sweat.
from fastapi import FastAPIimport httpx
app = FastAPI()
@app.get('/api-combo/')async def fetch_multi(): async with httpx.AsyncClient() as client: r1 = await client.get('https://api1.com/data') r2 = await client.get('https://api2.com/data') return {'api1': r1.json(), 'api2': r2.json()}You write it naturally, FastAPI handles the concurrency. This is where FastAPI’s “modern” label comes from.
Winner: FastAPI, decisively. If you care about async, the other two feel like bolting wings onto a car; FastAPI is the wings.
The Admin Panel (Django’s Superpower)
Django’s admin is ridiculous. You define a model, register it with the admin, and boom—you get CRUD, filters, search, bulk operations, and permissions, all auto-generated.
from django.contrib import adminfrom .models import User, Post
admin.site.register(User)admin.site.register(Post)Visit /admin/, log in, and you’ve got a fully-functional admin interface. Add 10 lines of config and you’ve got custom actions, filters, and read-only fields.
Flask and FastAPI? You get nothing. You want an admin panel, you build it (reach for something like Flask-Admin or write a separate React app). For a small SaaS or homelab app, Django’s admin can save you 20 hours of development.
Templates vs API-Only
Django
Django ships with template support. If you’re building a server-rendered web app (forms, HTML pages, progressive enhancement), Django is built for it.
<form method="post"> {% csrf_token %} {{ form }} <button>Submit</button></form>Django handles CSRF tokens, form rendering, and session management out of the box.
Flask
Flask includes Jinja2 templating, same as Django. It’s equally capable, equally minimal.
<form method="post"> <input name="email" type="email" required> <button>Submit</button></form>You manage CSRF yourself (reach for Flask-WTF).
FastAPI
FastAPI is API-first. It ships with template support (via Jinja2) but assumes you’re either building a JSON API or a JavaScript-driven frontend. Most people use FastAPI to power a React/Vue/Svelte app or a mobile app.
from fastapi import FastAPIfrom fastapi.responses import HTMLResponse
app = FastAPI()
@app.get('/', response_class=HTMLResponse)async def root(): return '<h1>Hello</h1>'You can render HTML from FastAPI, but it’s not the happy path. The happy path is JSON.
OpenAPI / Auto-Generated Docs
FastAPI
Built in. Visit /docs and you get an interactive Swagger UI showing every endpoint, parameter, response schema, and you can test endpoints right there. Visit /redoc for ReDoc.
This alone saves hours when you’re building a homelab API—no separate documentation to maintain, it’s always in sync.
Flask
Nothing built in. You can add flasgger or flask-restx, but it’s a third-party add-on.
Django
You need drf-spectacular to generate OpenAPI docs. It works great, but it’s extra.
This is a real win for FastAPI. If you’re building an API and you care about developer experience (yours or your team’s), auto-docs are huge.
Deployment and Running
Flask
Flask ships with a dev server. For production, you run it with Gunicorn (or uWSGI), which is a WSGI server (synchronous, multi-process).
gunicorn -w 4 -b 0.0.0.0:8000 app:app-w 4 means four worker processes. If one gets stuck on a slow request, the others keep processing. Simple, battle-tested, reliable.
Django
Same story. Gunicorn + WSGI. Django’s development server is a dev-only toy, exactly like Flask’s.
gunicorn -w 4 config.wsgi:applicationThe main difference is that Django expects a wsgi.py file in your project config.
FastAPI
FastAPI uses Uvicorn, an ASGI server (async, event-loop based).
uvicorn app:app --workers 4Uvicorn is single-process, event-loop driven. One worker can handle thousands of concurrent connections. If you’re deploying to Kubernetes or a serverless platform (AWS Lambda, Fly.io), Uvicorn’s async model is often more efficient.
For homelab or small SaaS: Gunicorn (Flask/Django) is simpler and more forgiving. For high-concurrency APIs: Uvicorn (FastAPI) wins.
Middleware and Auth
Flask
from flask import Flask, requestfrom functools import wraps
app = Flask(__name__)
def require_api_key(f): @wraps(f) def decorated(*args, **kwargs): api_key = request.headers.get('X-API-Key') if not api_key or api_key != 'secret123': return {'error': 'Unauthorized'}, 401 return f(*args, **kwargs) return decorated
@app.route('/protected/')@require_api_keydef protected(): return {'data': 'secret'}Flask uses decorators for middleware/auth. It’s flexible.
FastAPI
from fastapi import FastAPI, Header, HTTPException
app = FastAPI()
async def verify_api_key(x_api_key: str = Header(...)): if x_api_key != 'secret123': raise HTTPException(status_code=401, detail='Unauthorized') return x_api_key
@app.get('/protected/')async def protected(api_key: str = Depends(verify_api_key)): return {'data': 'secret'}FastAPI uses Depends() for dependency injection. It’s more typed, more composable, and integrates with OpenAPI docs (your header requirement shows up in /docs).
Django
from django.contrib.auth.decorators import login_requiredfrom django.http import JsonResponse
@login_requireddef protected(request): return JsonResponse({'data': 'secret'})Django’s auth is tied to Django’s session and user model. If you want a custom auth flow (API keys, OAuth, JWT), you reach for Django REST Framework and third-party packages.
The Decision Tree
Pick Django if:
- You need a full-stack app (server-rendered HTML + database)
- You want an auto-generated admin panel (this is huge)
- You’re building a classic web app (forms, user accounts, multi-page site)
- You want batteries included and don’t mind some opinionation
- Time to first feature: Weekend
Pick Flask if:
- You want minimal framework and full control over your stack
- You’re building a REST API with a specific tech stack (SQLAlchemy, WTForms, etc.)
- You need to integrate with existing code or libraries that dictate your choices
- You’re building a custom admin interface anyway
- Time to first feature: Depends on what you build
Pick FastAPI if:
- You’re building a modern async-first API
- Auto-generated OpenAPI docs matter to you
- Type hints and validation matter to you
- You want to lean on async I/O (multiple API calls, WebSocket support, Server-Sent Events)
- You’re deploying to Kubernetes, Fly.io, or serverless
- Time to first feature: Quick (if API-only), slower (if you need a template-based UI)
The Real Talk
Django gets memed on for being bloated. It’s not. It ships with a ton of stuff because most apps need most of that stuff. If you don’t need it, don’t use it. But the admin panel, the ORM, the migrations, the built-in auth—that stuff saves weeks on a real project.
Flask is the “just give me the framework, I’ll pick everything else” option. You get freedom. You also get the chance to pick badly. It’s honest about what it is: a WSGI framework that handles routing and request/response.
FastAPI is the future in some ways—async-first, type-driven, auto-documented. But it’s also overkill for a simple CRUD app where one request per page is fine. Use the right tool for the job. If your app is I/O-bound and concurrent, FastAPI shines. If it’s not, Gunicorn + Flask is simpler and just as fast.
Your 2 AM self will thank you if you pick the one that matches your actual constraints, not the one that’s trendy.