Backend Development

Python Flask: Building REST APIs

Mayur Dabhi
Mayur Dabhi
May 28, 2026
14 min read

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.

Why Flask?

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:

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:

Client Browser / App HTTP WSGI Server Gunicorn / uWSGI Flask App URL Dispatcher View Function jsonify(data) SQLAlchemy / JWT Marshmallow / etc. JSON resp HTTP Response

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:

1

Create a virtual environment

Python ships with venv — no extra install needed. This keeps your project's packages isolated from the system Python.

2

Install Flask and core extensions

Install Flask along with flask-sqlalchemy for database access, flask-jwt-extended for authentication, and marshmallow for serialization.

3

Scaffold the project structure

Organise code into an app/ package with blueprints for each domain area (users, auth, products, etc.).

4

Set up environment config

Store secrets in a .env file and load them with python-dotenv. Never hard-code database URLs or secret keys.

Terminal — project bootstrap
# 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.

create_app() Application Factory — app/__init__.py auth blueprint POST /auth/login POST /auth/register users blueprint GET /users GET /users/<id> products blueprint GET /products POST /products Shared: SQLAlchemy db · JWT manager · Migrate

Flask Blueprint architecture — each domain owns its routes

app/__init__.py — Application factory
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
app/config.py
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

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

app/users/routes.py — CRUD with SQLAlchemy
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
Never Trust User Input

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.

app/auth/routes.py — register, login, refresh
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:

Protected endpoint example
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.

app/__init__.py — global error handlers
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:

Using abort() for clean error responses
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

CodeMeaningWhen to use
200 OKSuccessGET, PUT returning updated resource
201 CreatedResource createdSuccessful POST
204 No ContentSuccess, no bodySuccessful DELETE
400 Bad RequestInvalid inputMissing required fields, validation failure
401 UnauthorizedNot authenticatedMissing / invalid token
403 ForbiddenNot authorizedAuthenticated but insufficient permissions
404 Not FoundResource missingID doesn't exist in database
409 ConflictDuplicate resourceEmail already registered
422 UnprocessableSemantic errorStructurally valid JSON but fails business rules
500 Server ErrorUnexpected failureUnhandled 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.

tests/conftest.py — shared fixtures
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()
tests/test_auth.py — endpoint tests
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 Your Tests

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:

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() and db.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 HTTPException and bare Exception handlers 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.

Python Flask REST API SQLAlchemy JWT Backend
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.