Skip to content
Go back

FastAPI vs Flask vs Django Picked Right

By SumGuy 13 min read
FastAPI vs Flask vs Django Picked Right

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

app.py
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

app.py
from fastapi import FastAPI
app = FastAPI()
@app.get('/')
def hello():
return {'message': 'hello world'}
# Run with: uvicorn app:app --reload

FastAPI 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

Terminal window
django-admin startproject mysite
python manage.py runserver

Django 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

flask_routes.py
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

fastapi_routes.py
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

django_urls.py
from django.urls import path
from . import views
urlpatterns = [
path('users/<int:user_id>/', views.get_user),
path('posts/<slug:slug>/', views.post_detail),
]
# In views.py
from 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)

flask_validation.py
from flask import Flask, request
from 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']}, 201

You 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)

fastapi_validation.py
from fastapi import FastAPI
from 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)

django_validation.py
from django import forms
from django.views import View
from 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:

flask_db.py
from flask import Flask
from 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.

django_models.py
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.py
from 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 UI

Django’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)

fastapi_db.py
from fastapi import FastAPI
from 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 user

SQLModel 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.”

flask_async.py
from flask import Flask
import 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.

fastapi_async.py
from fastapi import FastAPI
import 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.

django_admin.py
from django.contrib import admin
from .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.

template.html
<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.

template.html
<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 FastAPI
from 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).

Terminal window
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.

Terminal window
gunicorn -w 4 config.wsgi:application

The 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).

Terminal window
uvicorn app:app --workers 4

Uvicorn 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

flask_auth.py
from flask import Flask, request
from 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_key
def protected():
return {'data': 'secret'}

Flask uses decorators for middleware/auth. It’s flexible.

FastAPI

fastapi_auth.py
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

django_auth.py
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
@login_required
def 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:

Pick Flask if:

Pick FastAPI if:

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.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
pfSense vs OPNsense in 2026
Next Post
NVMe Heatsinks That Actually Cool

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts