Flask REST API: The Complete Guide for 2026
Flask remains one of the most popular Python frameworks for building REST APIs. Its minimal core, extensive ecosystem, and straightforward approach make it the go-to choice for developers who want control over their stack without the overhead of a full framework. This guide walks you through building a production-ready Flask REST API from scratch, covering everything from initial setup to deployment.
Table of Contents
- 1. Project Setup and Structure
- 2. Creating Routes and Endpoints
- 3. Request Handling
- 4. Response Formatting
- 5. Error Handling
- 6. SQLAlchemy Database Integration
- 7. Request Validation
- 8. Authentication
- 9. CORS Configuration
- 10. Testing with pytest
- 11. Deployment
- 12. Best Practices and Patterns
- Frequently Asked Questions
1. Project Setup and Structure
Start by creating a virtual environment and installing Flask. Use Python 3.12 or later for the best experience in 2026.
# Create project and virtual environment
mkdir flask-api && cd flask-api
python -m venv venv
source venv/bin/activate # Linux/macOS
# Install Flask and common extensions
pip install flask flask-sqlalchemy flask-migrate flask-jwt-extended
pip install flask-cors marshmallow python-dotenv gunicorn
Recommended Project Structure
Organize your project using the application factory pattern. This makes testing easier and allows multiple configurations.
flask-api/
app/
__init__.py # Application factory
config.py # Configuration classes
models/
__init__.py
user.py
item.py
routes/
__init__.py
auth.py
items.py
schemas/
__init__.py
item.py
utils/
errors.py
tests/
conftest.py
test_items.py
.env
requirements.txt
wsgi.py
Application Factory
# app/__init__.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_jwt_extended import JWTManager
from flask_cors import CORS
db = SQLAlchemy()
migrate = Migrate()
jwt = JWTManager()
def create_app(config_name='development'):
app = Flask(__name__)
# Load configuration
if config_name == 'testing':
app.config.from_object('app.config.TestingConfig')
elif config_name == 'production':
app.config.from_object('app.config.ProductionConfig')
else:
app.config.from_object('app.config.DevelopmentConfig')
# Initialize extensions
db.init_app(app)
migrate.init_app(app, db)
jwt.init_app(app)
CORS(app)
# Register blueprints
from app.routes.auth import auth_bp
from app.routes.items import items_bp
app.register_blueprint(auth_bp, url_prefix='/api/auth')
app.register_blueprint(items_bp, url_prefix='/api/items')
# Register error handlers
from app.utils.errors import register_error_handlers
register_error_handlers(app)
return app
Configuration Classes
# app/config.py
import os
from datetime import timedelta
class BaseConfig:
SECRET_KEY = os.environ.get('SECRET_KEY', 'change-me-in-production')
SQLALCHEMY_TRACK_MODIFICATIONS = False
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
class DevelopmentConfig(BaseConfig):
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', 'sqlite:///dev.db')
class TestingConfig(BaseConfig):
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
class ProductionConfig(BaseConfig):
SQLALCHEMY_DATABASE_URI = os.environ['DATABASE_URL']
JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=15)
2. Creating Routes and Endpoints
Flask uses blueprints to organize routes into logical groups. Each resource gets its own blueprint with endpoints for standard CRUD operations.
# app/routes/items.py
from flask import Blueprint, request, jsonify
from app.models.item import Item
from app import db
items_bp = Blueprint('items', __name__)
@items_bp.route('/', methods=['GET'])
def get_items():
items = Item.query.all()
return jsonify([item.to_dict() for item in items]), 200
@items_bp.route('/<int:item_id>', methods=['GET'])
def get_item(item_id):
item = db.session.get(Item, item_id)
if not item:
return jsonify({'error': 'Item not found'}), 404
return jsonify(item.to_dict()), 200
@items_bp.route('/', methods=['POST'])
def create_item():
data = request.get_json()
if not data or 'name' not in data:
return jsonify({'error': 'Name is required'}), 400
item = Item(name=data['name'], description=data.get('description', ''))
db.session.add(item)
db.session.commit()
return jsonify(item.to_dict()), 201
@items_bp.route('/<int:item_id>', methods=['PUT'])
def update_item(item_id):
item = db.session.get(Item, item_id)
if not item:
return jsonify({'error': 'Item not found'}), 404
data = request.get_json()
item.name = data.get('name', item.name)
item.description = data.get('description', item.description)
db.session.commit()
return jsonify(item.to_dict()), 200
@items_bp.route('/<int:item_id>', methods=['DELETE'])
def delete_item(item_id):
item = db.session.get(Item, item_id)
if not item:
return jsonify({'error': 'Item not found'}), 404
db.session.delete(item)
db.session.commit()
return jsonify({'message': 'Item deleted'}), 200
Test Your Endpoints
Use the DevToolbox HTTP Tester to send requests to your Flask API during development. Check status codes with the HTTP Status Code Lookup.
3. Request Handling
JSON Request Bodies
Flask parses JSON bodies automatically with request.get_json(). Always handle the case where the body is missing or malformed.
@items_bp.route('/', methods=['POST'])
def create_item():
# Returns None if Content-Type is not application/json or body is invalid
data = request.get_json(silent=True)
if data is None:
return jsonify({'error': 'Request body must be valid JSON'}), 400
name = data.get('name')
price = data.get('price', 0.0)
tags = data.get('tags', [])
return jsonify({'received': data}), 201
Query Parameters and Pagination
@items_bp.route('/', methods=['GET'])
def get_items():
# /api/items?page=2&per_page=20&sort=name&category=tools
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
sort_by = request.args.get('sort', 'created_at')
category = request.args.get('category')
query = Item.query
if category:
query = query.filter_by(category=category)
pagination = query.order_by(sort_by).paginate(
page=page, per_page=min(per_page, 100), error_out=False
)
return jsonify({
'items': [item.to_dict() for item in pagination.items],
'total': pagination.total,
'page': pagination.page,
'has_next': pagination.has_next
}), 200
URL Parameter Types
@app.route('/items/<int:item_id>') # Integer
@app.route('/users/<username>') # String (default)
@app.route('/orders/<uuid:order_id>') # UUID
@app.route('/files/<path:filepath>') # Path (includes slashes)
4. Response Formatting
Always return consistent JSON structures. Create a helper function that every endpoint uses.
def api_response(data=None, message=None, status=200):
body = {}
if data is not None:
body['data'] = data
if message:
body['message'] = message
return jsonify(body), status
# Response with custom headers
@items_bp.route('/', methods=['POST'])
def create_item():
item = Item(name='New Item')
db.session.add(item)
db.session.commit()
response = jsonify(item.to_dict())
response.status_code = 201
response.headers['Location'] = f'/api/items/{item.id}'
return response
Common HTTP Status Codes for APIs
| Code | Meaning | When to Use |
|---|---|---|
200 | OK | Successful GET, PUT, PATCH, or DELETE |
201 | Created | Successful POST that creates a resource |
204 | No Content | Successful DELETE with no response body |
400 | Bad Request | Malformed request or validation error |
401 | Unauthorized | Missing or invalid authentication |
403 | Forbidden | Authenticated but lacks permission |
404 | Not Found | Resource does not exist |
409 | Conflict | Duplicate resource or state conflict |
422 | Unprocessable | Valid JSON but semantic validation failed |
500 | Server Error | Unhandled exception (never intentional) |
5. Error Handling
Register global error handlers so every error returns a consistent JSON response instead of HTML.
# app/utils/errors.py
from flask import jsonify
from werkzeug.exceptions import HTTPException
class APIError(Exception):
"""Custom API exception with status code and payload."""
def __init__(self, message, status_code=400, payload=None):
super().__init__()
self.message = message
self.status_code = status_code
self.payload = payload
def to_dict(self):
rv = {'error': self.message}
if self.payload:
rv['details'] = self.payload
return rv
def register_error_handlers(app):
@app.errorhandler(APIError)
def handle_api_error(error):
return jsonify(error.to_dict()), error.status_code
@app.errorhandler(HTTPException)
def handle_http_error(error):
return jsonify({'error': error.description, 'status': error.code}), error.code
@app.errorhandler(500)
def internal_error(error):
return jsonify({'error': 'Internal server error'}), 500
Use the custom exception in your route handlers:
from app.utils.errors import APIError
@items_bp.route('/', methods=['POST'])
def create_item():
data = request.get_json(silent=True)
if data is None:
raise APIError('Request body must be valid JSON', 400)
if 'name' not in data:
raise APIError('Validation failed', 422, payload={
'fields': {'name': 'This field is required'}
})
if Item.query.filter_by(name=data['name']).first():
raise APIError('An item with this name already exists', 409)
6. SQLAlchemy Database Integration
Flask-SQLAlchemy provides a clean interface for defining models and querying a database. Define models with relationships, serialization, and constraints.
# app/models/item.py
from datetime import datetime, timezone
from app import db
class Item(db.Model):
__tablename__ = 'items'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True)
description = db.Column(db.Text, default='')
price = db.Column(db.Float, default=0.0)
category = db.Column(db.String(50), index=True)
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
owner_id = db.Column(db.Integer, db.ForeignKey('users.id'))
owner = db.relationship('User', back_populates='items')
def to_dict(self):
return {
'id': self.id, 'name': self.name, 'description': self.description,
'price': self.price, 'category': self.category,
'is_active': self.is_active,
'created_at': self.created_at.isoformat(),
'updated_at': self.updated_at.isoformat(),
'owner_id': self.owner_id
}
# app/models/user.py
from werkzeug.security import generate_password_hash, check_password_hash
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_hash = db.Column(db.String(256), nullable=False)
is_admin = db.Column(db.Boolean, default=False)
items = db.relationship('Item', back_populates='owner', lazy='dynamic')
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def to_dict(self):
return {'id': self.id, 'username': self.username,
'email': self.email, 'is_admin': self.is_admin}
Database Migrations with Flask-Migrate
flask db init # Initialize (first time only)
flask db migrate -m "Add items table" # Create migration
flask db upgrade # Apply migrations
flask db downgrade # Roll back one migration
Work with JSON Data
Use the JSON Formatter to inspect API responses. The JSON Schema Generator can help you define response schemas for documentation.
7. Request Validation
Option A: Marshmallow (Recommended)
Marshmallow provides serialization, deserialization, and validation in one library. It integrates naturally with Flask and SQLAlchemy.
# app/schemas/item.py
from marshmallow import Schema, fields, validate, validates, ValidationError
class ItemCreateSchema(Schema):
name = fields.String(required=True, validate=validate.Length(min=1, max=100))
description = fields.String(load_default='', validate=validate.Length(max=500))
price = fields.Float(load_default=0.0, validate=validate.Range(min=0))
category = fields.String(validate=validate.OneOf(['tools', 'books', 'electronics']))
@validates('name')
def validate_name(self, value):
if value.strip() != value:
raise ValidationError('Name cannot have leading/trailing spaces')
class ItemUpdateSchema(Schema):
name = fields.String(validate=validate.Length(min=1, max=100))
description = fields.String(validate=validate.Length(max=500))
price = fields.Float(validate=validate.Range(min=0))
category = fields.String(validate=validate.OneOf(['tools', 'books', 'electronics']))
class ItemQuerySchema(Schema):
page = fields.Integer(load_default=1, validate=validate.Range(min=1))
per_page = fields.Integer(load_default=10, validate=validate.Range(min=1, max=100))
category = fields.String()
sort = fields.String(load_default='created_at',
validate=validate.OneOf(['name', 'price', 'created_at']))
Use schemas in your routes:
from marshmallow import ValidationError as MarshmallowError
from app.schemas.item import ItemCreateSchema, ItemQuerySchema
item_schema = ItemCreateSchema()
@items_bp.route('/', methods=['POST'])
def create_item():
try:
data = item_schema.load(request.get_json())
except MarshmallowError as err:
return jsonify({'error': 'Validation failed', 'details': err.messages}), 422
item = Item(**data)
db.session.add(item)
db.session.commit()
return jsonify(item.to_dict()), 201
Option B: Pydantic
from pydantic import BaseModel, Field, field_validator, ValidationError
class ItemCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
description: str = Field(default='', max_length=500)
price: float = Field(default=0.0, ge=0)
category: str | None = None
@field_validator('category')
@classmethod
def validate_category(cls, v):
if v and v not in ('tools', 'books', 'electronics'):
raise ValueError('Invalid category')
return v
@items_bp.route('/', methods=['POST'])
def create_item():
try:
data = ItemCreate.model_validate(request.get_json())
except ValidationError as err:
return jsonify({'error': 'Validation failed', 'details': err.errors()}), 422
item = Item(**data.model_dump(exclude_none=True))
db.session.add(item)
db.session.commit()
return jsonify(item.to_dict()), 201
8. Authentication
JWT Authentication with flask-jwt-extended
# app/routes/auth.py
from flask import Blueprint, request, jsonify
from flask_jwt_extended import (
create_access_token, create_refresh_token,
jwt_required, get_jwt_identity, get_jwt
)
from app.models.user import User
from app import db
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/register', methods=['POST'])
def register():
data = request.get_json()
if User.query.filter_by(username=data['username']).first():
return jsonify({'error': 'Username already taken'}), 409
user = User(username=data['username'], email=data['email'])
user.set_password(data['password'])
db.session.add(user)
db.session.commit()
return jsonify({'message': 'User created', 'user': user.to_dict()}), 201
@auth_bp.route('/login', methods=['POST'])
def login():
data = request.get_json()
user = User.query.filter_by(username=data.get('username')).first()
if not user or not user.check_password(data.get('password', '')):
return jsonify({'error': 'Invalid credentials'}), 401
access_token = create_access_token(
identity=str(user.id), additional_claims={'is_admin': user.is_admin}
)
refresh_token = create_refresh_token(identity=str(user.id))
return jsonify({'access_token': access_token,
'refresh_token': refresh_token, 'user': user.to_dict()}), 200
@auth_bp.route('/refresh', methods=['POST'])
@jwt_required(refresh=True)
def refresh():
new_token = create_access_token(identity=get_jwt_identity())
return jsonify({'access_token': new_token}), 200
Protecting Routes and Admin Access
from functools import wraps
# Basic protection - require valid JWT
@items_bp.route('/', methods=['POST'])
@jwt_required()
def create_item():
current_user_id = get_jwt_identity()
data = request.get_json()
item = Item(name=data['name'], owner_id=int(current_user_id))
db.session.add(item)
db.session.commit()
return jsonify(item.to_dict()), 201
# Custom decorator for admin-only routes
def admin_required(fn):
@wraps(fn)
@jwt_required()
def wrapper(*args, **kwargs):
claims = get_jwt()
if not claims.get('is_admin', False):
return jsonify({'error': 'Admin access required'}), 403
return fn(*args, **kwargs)
return wrapper
@items_bp.route('/<int:item_id>', methods=['DELETE'])
@admin_required
def delete_item(item_id):
item = db.session.get(Item, item_id)
if not item:
return jsonify({'error': 'Not found'}), 404
db.session.delete(item)
db.session.commit()
return jsonify({'message': 'Deleted'}), 200
API Key Authentication
For server-to-server communication, API keys are simpler than JWT.
def require_api_key(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
api_key = request.headers.get('X-API-Key')
if not api_key:
return jsonify({'error': 'API key required'}), 401
key_record = APIKey.query.filter_by(key=api_key, is_active=True).first()
if not key_record:
return jsonify({'error': 'Invalid API key'}), 401
from flask import g
g.api_client = key_record.client_name
return fn(*args, **kwargs)
return wrapper
9. CORS Configuration
Cross-Origin Resource Sharing (CORS) is required when your frontend runs on a different domain than your API.
from flask_cors import CORS
# Development: allow all origins
CORS(app)
# Production: restrict origins
CORS(app, resources={
r"/api/*": {
"origins": ["https://yourdomain.com", "https://app.yourdomain.com"],
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
"allow_headers": ["Content-Type", "Authorization"],
"max_age": 3600
}
})
10. Testing with pytest
Flask's test client lets you simulate HTTP requests without starting a server. Combine it with pytest fixtures for clean, repeatable tests.
# tests/conftest.py
import pytest
from app import create_app, db
@pytest.fixture
def app():
app = create_app('testing')
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def auth_headers(client):
"""Create a test user and return auth headers."""
client.post('/api/auth/register', json={
'username': 'testuser', 'email': 'test@example.com',
'password': 'securepass123'
})
response = client.post('/api/auth/login', json={
'username': 'testuser', 'password': 'securepass123'
})
token = response.get_json()['access_token']
return {'Authorization': f'Bearer {token}'}
# tests/test_items.py
def test_create_item(client, auth_headers):
resp = client.post('/api/items/', json={
'name': 'Test Item', 'price': 9.99
}, headers=auth_headers)
assert resp.status_code == 201
assert resp.get_json()['name'] == 'Test Item'
def test_create_item_validation(client, auth_headers):
resp = client.post('/api/items/', json={'description': 'No name'}, headers=auth_headers)
assert resp.status_code == 422
def test_create_item_unauthorized(client):
resp = client.post('/api/items/', json={'name': 'Test'})
assert resp.status_code == 401
def test_get_item_not_found(client):
assert client.get('/api/items/999').status_code == 404
def test_update_and_delete(client, auth_headers):
resp = client.post('/api/items/', json={'name': 'Original'}, headers=auth_headers)
item_id = resp.get_json()['id']
update = client.put(f'/api/items/{item_id}', json={'name': 'Updated'}, headers=auth_headers)
assert update.get_json()['name'] == 'Updated'
delete = client.delete(f'/api/items/{item_id}', headers=auth_headers)
assert delete.status_code == 200
assert client.get(f'/api/items/{item_id}').status_code == 404
def test_pagination(client, auth_headers):
for i in range(15):
client.post('/api/items/', json={'name': f'Item {i}'}, headers=auth_headers)
resp = client.get('/api/items/?page=1&per_page=10')
data = resp.get_json()
assert len(data['items']) == 10
assert data['total'] == 15
# Run tests
pytest tests/ -v
pytest tests/ --cov=app --cov-report=term-missing
11. Deployment
Gunicorn (WSGI Server)
Never use Flask's built-in server in production. Gunicorn handles concurrent requests and worker management.
# wsgi.py
from app import create_app
app = create_app('production')
# gunicorn.conf.py
import multiprocessing
bind = "0.0.0.0:8000"
workers = multiprocessing.cpu_count() * 2 + 1
timeout = 120
accesslog = "-"
errorlog = "-"
# Run: gunicorn -c gunicorn.conf.py wsgi:app
Docker
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN adduser --disabled-password --no-create-home appuser
USER appuser
EXPOSE 8000
CMD ["gunicorn", "-c", "gunicorn.conf.py", "wsgi:app"]
# docker-compose.yml
services:
api:
build: .
ports: ["8000:8000"]
environment:
- DATABASE_URL=postgresql://user:pass@db:5432/apidb
- SECRET_KEY=${SECRET_KEY}
- FLASK_ENV=production
depends_on:
db: { condition: service_healthy }
db:
image: postgres:16
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: apidb
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
Environment Variables
# .env (never commit this file)
SECRET_KEY=your-super-secret-key-here
DATABASE_URL=postgresql://user:pass@localhost:5432/apidb
# .flaskenv (safe to commit)
FLASK_APP=wsgi.py
FLASK_DEBUG=0
12. Best Practices and Common Patterns
Rate Limiting
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter = Limiter(app=app, key_func=get_remote_address,
default_limits=["200 per day", "50 per hour"])
@app.route('/api/auth/login', methods=['POST'])
@limiter.limit("5 per minute")
def login():
...
Request Logging
import logging
from logging.handlers import RotatingFileHandler
def configure_logging(app):
handler = RotatingFileHandler('api.log', maxBytes=10_000_000, backupCount=5)
handler.setFormatter(logging.Formatter(
'[%(asctime)s] %(levelname)s in %(module)s: %(message)s'
))
app.logger.addHandler(handler)
app.logger.setLevel(logging.INFO)
@app.after_request
def log_response(response):
app.logger.info('%s %s %s %s', request.remote_addr,
request.method, request.full_path, response.status_code)
return response
API Versioning
v1 = Blueprint('v1', __name__, url_prefix='/api/v1')
v2 = Blueprint('v2', __name__, url_prefix='/api/v2')
@v1.route('/items')
def get_items_v1():
return jsonify([item.to_dict() for item in Item.query.all()])
@v2.route('/items')
def get_items_v2():
page = request.args.get('page', 1, type=int)
pagination = Item.query.paginate(page=page, per_page=20)
return jsonify({
'data': [item.to_dict() for item in pagination.items],
'meta': {'total': pagination.total, 'page': page}
})
Health Check Endpoint
@app.route('/health')
def health():
try:
db.session.execute(db.text('SELECT 1'))
db_status = 'connected'
except Exception:
db_status = 'disconnected'
return jsonify({
'status': 'healthy' if db_status == 'connected' else 'degraded',
'database': db_status, 'version': '1.0.0'
}), 200 if db_status == 'connected' else 503
Quick Reference: Essential Patterns
| Pattern | Implementation |
|---|---|
| App factory | create_app() function in __init__.py |
| Blueprints | One blueprint per resource, registered with URL prefix |
| Config | Class-based with env-specific subclasses |
| Validation | Marshmallow schemas or pydantic models |
| Auth | JWT for user APIs, API keys for service-to-service |
| Errors | Global error handlers returning JSON |
| Testing | pytest + app.test_client() + in-memory SQLite |
| Deployment | Gunicorn + Docker + environment variables |
Frequently Asked Questions
What is the difference between Flask and Django for building REST APIs?
Flask is a microframework that gives you full control over which components to use, making it ideal for lightweight APIs and microservices. Django is a batteries-included framework with built-in ORM, admin panel, and authentication. Flask is better when you want flexibility and a small footprint, while Django (with Django REST Framework) suits large applications that benefit from opinionated structure. Read more in our Django Complete Guide.
How do I handle authentication in a Flask REST API?
The most common approaches are JWT (JSON Web Tokens) using flask-jwt-extended, API keys sent via headers, and OAuth 2.0. For stateless APIs, JWT is the standard choice: the client sends a token in the Authorization header as "Bearer <token>", and the server validates it on each request using @jwt_required(). See the Authentication section above for full implementation details.
Should I use Flask-RESTful or plain Flask for building APIs?
Plain Flask with jsonify is sufficient for most APIs and keeps your dependency count low. Flask-RESTful adds class-based resource routing, which can help organize larger APIs. As of 2026, most developers prefer plain Flask with marshmallow or pydantic for validation, since Flask-RESTful's request parser is deprecated in favor of these libraries.
How do I deploy a Flask API to production?
Never use Flask's built-in development server in production. Use Gunicorn (gunicorn -w 4 -b 0.0.0.0:8000 wsgi:app) behind a reverse proxy like Nginx. For containers, use Docker. Set up environment variables for secrets, health check endpoints, and proper logging. See the Deployment section for complete Docker and Gunicorn configs.
How do I test a Flask REST API?
Flask provides a built-in test client via app.test_client() that simulates HTTP requests without running a server. Use pytest with fixtures to create the test client and a test database. Write tests for each endpoint covering success cases, validation errors, authentication failures, and edge cases. Use an in-memory SQLite database for fast execution. See the full Testing section for working examples.