Python Flask: Building REST APIs
Python Flask has cemented itself as the go-to micro-framework for building lightweight, flexible REST APIs. Unlike Django's "batteries included" philosophy, Flask gives you the freedom to assemble exactly the tools you need — nothing more, nothing less. The result is lean, readable code that scales from a weekend prototype to a production service handling millions of requests per day. In this guide you'll go from zero to a fully structured Flask API with database integration, JWT authentication, validation, and a proper test suite.
Flask is used in production at Pinterest, Netflix, LinkedIn, and Airbnb. With over 67,000 GitHub stars and a thriving ecosystem of extensions, it strikes the perfect balance between simplicity and power — making it ideal for microservices, data-science APIs, and rapid prototyping alike.
What is Flask?
Flask is a WSGI micro-framework written in Python. "Micro" doesn't mean feature-poor — it means Flask keeps a small, stable core and delegates everything else to extensions. At its heart, Flask is built on two battle-tested libraries:
- Werkzeug: The WSGI toolkit that handles request/response objects, URL routing, and HTTP utilities
- Jinja2: A fast templating engine (used for HTML responses, though less relevant for pure JSON APIs)
When your client sends an HTTP request, Flask's Werkzeug layer parses it into a Request object, the URL dispatcher finds the matching view function, and that function returns a Response — usually jsonify() data for an API. Here's what that flow looks like:
Flask request/response lifecycle
Flask vs Django vs FastAPI
Choosing the right Python web framework depends on your project needs. Here's how the three major options compare:
| Feature | Flask | Django | FastAPI |
|---|---|---|---|
| Philosophy | Micro-framework, flexible | Batteries included | Async-first, type hints |
| Learning curve | Low | Medium–High | Low–Medium |
| Performance | Good (sync) | Good (sync) | Excellent (async) |
| Auto docs | Via extensions | Via DRF | Built-in (OpenAPI) |
| Best for | Microservices, quick APIs | Full-stack web apps | High-throughput async APIs |
| ORM | SQLAlchemy (extension) | Built-in Django ORM | SQLAlchemy / Tortoise |
Installation and Project Setup
Always isolate Flask projects inside a virtual environment to avoid dependency conflicts between projects. Follow these steps to get a clean project off the ground:
Create a virtual environment
Python ships with venv — no extra install needed. This keeps your project's packages isolated from the system Python.
Install Flask and core extensions
Install Flask along with flask-sqlalchemy for database access, flask-jwt-extended for authentication, and marshmallow for serialization.
Scaffold the project structure
Organise code into an app/ package with blueprints for each domain area (users, auth, products, etc.).
Set up environment config
Store secrets in a .env file and load them with python-dotenv. Never hard-code database URLs or secret keys.
# Create and activate virtual environment
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# Install dependencies
pip install flask flask-sqlalchemy flask-jwt-extended \
flask-migrate marshmallow python-dotenv pytest
# Freeze versions
pip freeze > requirements.txt
# Project layout
my-api/
├── app/
│ ├── __init__.py # Application factory
│ ├── config.py # Configuration classes
│ ├── models.py # SQLAlchemy models
│ ├── auth/
│ │ ├── __init__.py
│ │ └── routes.py # /auth blueprint
│ └── users/
│ ├── __init__.py
│ └── routes.py # /users blueprint
├── tests/
│ └── test_users.py
├── .env
└── run.py
Routing and HTTP Methods
Flask routes map URL patterns to Python functions using the @app.route() decorator. Each route specifies which HTTP methods it accepts — if you omit methods, Flask defaults to GET only.
Return a list or single resource as JSON:
from flask import Flask, jsonify
app = Flask(__name__)
users = [
{"id": 1, "name": "Alice", "email": "alice@example.com"},
{"id": 2, "name": "Bob", "email": "bob@example.com"},
]
@app.route("/users", methods=["GET"])
def get_users():
return jsonify(users), 200
@app.route("/users/<int:user_id>", methods=["GET"])
def get_user(user_id):
user = next((u for u in users if u["id"] == user_id), None)
if user is None:
return jsonify({"error": "User not found"}), 404
return jsonify(user), 200
Accept JSON body data to create a resource:
from flask import request
@app.route("/users", methods=["POST"])
def create_user():
data = request.get_json()
# Basic validation
if not data or "name" not in data or "email" not in data:
return jsonify({"error": "name and email are required"}), 400
new_user = {
"id": len(users) + 1,
"name": data["name"],
"email": data["email"],
}
users.append(new_user)
return jsonify(new_user), 201
Update or delete an existing resource:
@app.route("/users/<int:user_id>", methods=["PUT"])
def update_user(user_id):
data = request.get_json()
user = next((u for u in users if u["id"] == user_id), None)
if user is None:
return jsonify({"error": "User not found"}), 404
user.update({k: v for k, v in data.items() if k in ("name", "email")})
return jsonify(user), 200
@app.route("/users/<int:user_id>", methods=["DELETE"])
def delete_user(user_id):
global users
users = [u for u in users if u["id"] != user_id]
return "", 204 # 204 No Content — standard delete response
Flask URL converters let you type-check path segments automatically:
# int converter — raises 404 if segment is not an integer
@app.route("/posts/<int:post_id>")
def get_post(post_id): ...
# string converter (default) — matches any non-slash text
@app.route("/users/<string:username>")
def get_by_username(username): ...
# path converter — matches slashes too (useful for file paths)
@app.route("/files/<path:filepath>")
def get_file(filepath): ...
# Multiple segments
@app.route("/users/<int:user_id>/posts/<int:post_id>")
def get_user_post(user_id, post_id): ...
Blueprints and the Application Factory
When your API grows beyond a handful of routes, a single app.py becomes unmanageable. Flask Blueprints let you split routes into logical modules, and the application factory pattern makes your app testable and configurable across environments.
Flask Blueprint architecture — each domain owns its routes
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_jwt_extended import JWTManager
from flask_migrate import Migrate
db = SQLAlchemy()
jwt = JWTManager()
migrate = Migrate()
def create_app(config_name="development"):
app = Flask(__name__)
# Load config
from app.config import config_map
app.config.from_object(config_map[config_name])
# Initialise extensions
db.init_app(app)
jwt.init_app(app)
migrate.init_app(app, db)
# Register blueprints
from app.auth.routes import auth_bp
from app.users.routes import users_bp
app.register_blueprint(auth_bp, url_prefix="/auth")
app.register_blueprint(users_bp, url_prefix="/users")
return app
import os
from dotenv import load_dotenv
load_dotenv()
class BaseConfig:
SECRET_KEY = os.getenv("SECRET_KEY", "change-me")
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "change-me-jwt")
SQLALCHEMY_TRACK_MODIFICATIONS = False
class DevelopmentConfig(BaseConfig):
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.getenv(
"DATABASE_URL", "sqlite:///dev.db"
)
class ProductionConfig(BaseConfig):
DEBUG = False
SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL")
config_map = {
"development": DevelopmentConfig,
"production": ProductionConfig,
}
Database Integration with SQLAlchemy
Flask-SQLAlchemy wraps SQLAlchemy's powerful ORM in a Flask-friendly package. You define models as Python classes, and SQLAlchemy translates them to SQL tables — supporting SQLite for development and PostgreSQL/MySQL in production with zero code changes.
Defining Models
from datetime import datetime
from app import db
class User(db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password = db.Column(db.String(256), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# One-to-many: a user can have many posts
posts = db.relationship("Post", back_populates="author",
lazy="dynamic", cascade="all, delete")
def to_dict(self):
return {
"id": self.id,
"username": self.username,
"email": self.email,
"created_at": self.created_at.isoformat(),
}
class Post(db.Model):
__tablename__ = "posts"
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
body = db.Column(db.Text, nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
author = db.relationship("User", back_populates="posts")
CRUD Operations
from flask import Blueprint, jsonify, request
from app import db
from app.models import User
users_bp = Blueprint("users", __name__)
@users_bp.route("/", methods=["GET"])
def list_users():
page = request.args.get("page", 1, type=int)
limit = request.args.get("limit", 20, type=int)
pagination = User.query.order_by(User.created_at.desc()) \
.paginate(page=page, per_page=limit, error_out=False)
return jsonify({
"users": [u.to_dict() for u in pagination.items],
"total": pagination.total,
"page": pagination.page,
"pages": pagination.pages,
}), 200
@users_bp.route("/<int:user_id>", methods=["GET"])
def get_user(user_id):
user = User.query.get_or_404(user_id)
return jsonify(user.to_dict()), 200
@users_bp.route("/<int:user_id>", methods=["PUT"])
def update_user(user_id):
user = User.query.get_or_404(user_id)
data = request.get_json()
if "username" in data:
user.username = data["username"]
if "email" in data:
user.email = data["email"]
db.session.commit()
return jsonify(user.to_dict()), 200
@users_bp.route("/<int:user_id>", methods=["DELETE"])
def delete_user(user_id):
user = User.query.get_or_404(user_id)
db.session.delete(user)
db.session.commit()
return "", 204
SQLAlchemy's ORM automatically parameterizes all queries — so User.query.filter_by(email=email).first() is safe from SQL injection. Only risk arises if you pass user data into db.engine.execute() with raw string formatting. Always use the ORM or bound parameters.
JWT Authentication
JSON Web Tokens (JWT) are the standard for stateless API authentication. Flask-JWT-Extended makes issuing and verifying tokens straightforward. The typical flow is: the client posts credentials to /auth/login, receives an access token (short-lived) and a refresh token (long-lived), then sends the access token as a Bearer header on every protected request.
from flask import Blueprint, jsonify, request
from werkzeug.security import generate_password_hash, check_password_hash
from flask_jwt_extended import (
create_access_token, create_refresh_token,
jwt_required, get_jwt_identity,
)
from app import db
from app.models import User
auth_bp = Blueprint("auth", __name__)
@auth_bp.route("/register", methods=["POST"])
def register():
data = request.get_json() or {}
if not data.get("username") or not data.get("email") or not data.get("password"):
return jsonify({"error": "username, email and password required"}), 400
if User.query.filter_by(email=data["email"]).first():
return jsonify({"error": "Email already registered"}), 409
user = User(
username = data["username"],
email = data["email"],
password = generate_password_hash(data["password"]),
)
db.session.add(user)
db.session.commit()
return jsonify(user.to_dict()), 201
@auth_bp.route("/login", methods=["POST"])
def login():
data = request.get_json() or {}
user = User.query.filter_by(email=data.get("email")).first()
if not user or not check_password_hash(user.password, data.get("password", "")):
return jsonify({"error": "Invalid credentials"}), 401
access_token = create_access_token(identity=user.id)
refresh_token = create_refresh_token(identity=user.id)
return jsonify({"access_token": access_token,
"refresh_token": refresh_token}), 200
@auth_bp.route("/refresh", methods=["POST"])
@jwt_required(refresh=True)
def refresh():
identity = get_jwt_identity()
access_token = create_access_token(identity=identity)
return jsonify({"access_token": access_token}), 200
@auth_bp.route("/me", methods=["GET"])
@jwt_required()
def me():
user_id = get_jwt_identity()
user = User.query.get_or_404(user_id)
return jsonify(user.to_dict()), 200
Protect any route by adding @jwt_required() as a decorator. Flask-JWT-Extended will automatically reject requests with missing or expired tokens:
from flask_jwt_extended import jwt_required, get_jwt_identity
@users_bp.route("/profile", methods=["GET"])
@jwt_required() # returns 401 if token missing or expired
def my_profile():
user_id = get_jwt_identity()
user = User.query.get_or_404(user_id)
return jsonify(user.to_dict()), 200
# Client sends:
# Authorization: Bearer <access_token>
Error Handling and Response Formatting
Consistent error responses are the hallmark of a well-designed API. Flask lets you register custom error handlers globally so every error — 404, 422, 500 — returns the same structured JSON format instead of an HTML page.
from flask import jsonify
from werkzeug.exceptions import HTTPException
def register_error_handlers(app):
@app.errorhandler(HTTPException)
def http_error(e):
return jsonify({
"error": e.name,
"message": e.description,
"status": e.code,
}), e.code
@app.errorhandler(Exception)
def unhandled_error(e):
app.logger.error("Unhandled exception: %s", str(e))
return jsonify({
"error": "Internal Server Error",
"message": "An unexpected error occurred.",
"status": 500,
}), 500
# In create_app():
# register_error_handlers(app)
Use abort() anywhere in your view functions to trigger the registered handler immediately:
from flask import abort
@users_bp.route("/<int:user_id>", methods=["GET"])
def get_user(user_id):
user = User.query.get(user_id)
if user is None:
abort(404, description=f"User {user_id} not found")
return jsonify(user.to_dict()), 200
# Response:
# {
# "error": "Not Found",
# "message": "User 42 not found",
# "status": 404
# }
Common HTTP Status Codes Quick Reference
| Code | Meaning | When to use |
|---|---|---|
| 200 OK | Success | GET, PUT returning updated resource |
| 201 Created | Resource created | Successful POST |
| 204 No Content | Success, no body | Successful DELETE |
| 400 Bad Request | Invalid input | Missing required fields, validation failure |
| 401 Unauthorized | Not authenticated | Missing / invalid token |
| 403 Forbidden | Not authorized | Authenticated but insufficient permissions |
| 404 Not Found | Resource missing | ID doesn't exist in database |
| 409 Conflict | Duplicate resource | Email already registered |
| 422 Unprocessable | Semantic error | Structurally valid JSON but fails business rules |
| 500 Server Error | Unexpected failure | Unhandled exception (never expose details) |
Testing Flask APIs
Flask's built-in test client lets you make HTTP requests against your application in memory — no running server needed. Combine it with pytest and an in-memory SQLite database to get fast, isolated tests for every endpoint.
import pytest
from app import create_app, db as _db
@pytest.fixture(scope="session")
def app():
app = create_app("testing")
app.config.update({
"TESTING": True,
"SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
})
with app.app_context():
_db.create_all()
yield app
_db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture(autouse=True)
def clean_db(app):
yield
with app.app_context():
for table in reversed(_db.metadata.sorted_tables):
_db.session.execute(table.delete())
_db.session.commit()
def test_register_success(client):
res = client.post("/auth/register", json={
"username": "alice",
"email": "alice@example.com",
"password": "securepass123",
})
assert res.status_code == 201
data = res.get_json()
assert data["email"] == "alice@example.com"
assert "password" not in data # never leak hashed password
def test_register_duplicate_email(client):
payload = {"username": "bob", "email": "bob@test.com", "password": "pass"}
client.post("/auth/register", json=payload)
res = client.post("/auth/register", json=payload)
assert res.status_code == 409
def test_login_returns_tokens(client):
client.post("/auth/register", json={
"username": "carol", "email": "carol@test.com", "password": "mypassword"
})
res = client.post("/auth/login", json={
"email": "carol@test.com", "password": "mypassword"
})
assert res.status_code == 200
data = res.get_json()
assert "access_token" in data
assert "refresh_token" in data
def test_protected_route_without_token(client):
res = client.get("/auth/me")
assert res.status_code == 401
Run the full test suite with pytest -v. Use pytest --cov=app (requires pytest-cov) to see coverage reports. Aim for >80% coverage on your route handlers before shipping to production.
Deployment Checklist
Before pushing a Flask API to production, tick off these essentials to avoid common pitfalls:
- Use Gunicorn or uWSGI as the production WSGI server — never Flask's built-in server (
flask run). Example:gunicorn -w 4 "app:create_app()" - Set
DEBUG = False— debug mode exposes your source code and allows arbitrary code execution via the interactive debugger - Rotate secrets — generate strong, random values for
SECRET_KEYandJWT_SECRET_KEYusingpython -c "import secrets; print(secrets.token_hex(32))" - Database migrations — use Flask-Migrate:
flask db init→flask db migrate -m "initial"→flask db upgrade - Reverse proxy with Nginx — place Nginx in front of Gunicorn for TLS termination, rate limiting, and static file serving
- Environment variables — store all config in
.envor a secrets manager; never commit credentials to version control - Enable CORS headers — use
flask-cors:CORS(app, origins=["https://yourdomain.com"])
Key Takeaways
- Factory pattern — use
create_app()to wire extensions once and support multiple configs (dev, test, prod) - Blueprints — split routes by domain; each blueprint is independently testable and reusable
- SQLAlchemy ORM — parameterized queries by default; use
db.session.commit()anddb.session.rollback()consistently - JWT with refresh tokens — short-lived access tokens (15 min) + long-lived refresh tokens (30 days) is the industry-standard pattern
- Global error handlers — register
HTTPExceptionand bareExceptionhandlers to return consistent JSON error shapes - Test client — Flask's in-memory test client + SQLite makes endpoint tests fast without spinning up real infrastructure
"Flask's simplicity is its strength. It forces you to understand what you're building instead of hiding it behind magic — and that understanding is what makes your APIs maintainable."
You now have the foundation to build a production-grade REST API with Python Flask: clean project structure with blueprints, SQLAlchemy models with relationships, JWT-secured endpoints, consistent error handling, and a fast pytest suite. The ecosystem is rich — explore Flask-Limiter for rate limiting, Celery for background tasks, and Connexion for OpenAPI-driven design as your API grows.