Flask REST API Complete Guide 2026 — POST, abort, gunicorn, nginx
Flask 3.x is production-ready for REST APIs in 2026. It remains one of the most popular Python web frameworks precisely because it stays out of your way: no required ORM, no enforced project structure, no mandatory async runtime. You bring the pieces you need and leave out the ones you don't.
This guide consolidates everything you need to go from zero to a deployed Flask REST API: creating POST endpoints, handling JSON, using abort() with proper JSON error responses, structuring larger APIs with Blueprints, input validation without heavy dependencies, and running behind gunicorn and nginx in production.
When to choose Flask over FastAPI or Django REST Framework
| Flask 3.x | FastAPI | Django REST Framework | |
|---|---|---|---|
| Async support | Limited (via async views) | Native, first-class | Limited (via async views) |
| Learning curve | Low | Low–Medium | Medium–High |
| Built-in validation | None (bring your own) | Automatic via Pydantic | Serializers |
| ORM | None (use SQLAlchemy or any) | None (bring your own) | Django ORM |
| Performance | Good | Excellent | Good |
| Best use case | Small–medium APIs, simplicity, full control | High-performance APIs, auto-generated docs | Large apps already on Django |
Choose Flask when you want a small, understandable codebase, full control over dependencies, and no need for automatic async throughout. If you need WebSocket-heavy or high-concurrency workloads, FastAPI is worth evaluating. If you already have a Django project, Django REST Framework is the natural fit.
Setup
python -m venv venv
source venv/bin/activate
pip install flask gunicorn
Verify:
python -c "import flask; print(flask.__version__)"
Flask 3.x requires Python 3.9+.
Building POST Endpoints
A POST endpoint that accepts JSON and returns a created resource:
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/api/users', methods=['POST'])
def create_user():
data = request.get_json()
if not data:
return jsonify({'error': 'Request body must be JSON'}), 400
# Validate required fields
required = ['name', 'email']
missing = [f for f in required if f not in data]
if missing:
return jsonify({'error': f'Missing fields: {missing}'}), 400
# Process and return
user = {
'id': 1,
'name': data['name'],
'email': data['email']
}
return jsonify(user), 201
request.get_json() vs request.json vs request.data
request.get_json()— the right choice for API endpoints. Parses the body as JSON and returns a Python dict/list. ReturnsNoneif parsing fails (unlesssilent=False). Accepts optional arguments:force=Trueskips theContent-Typecheck,silent=Truesuppresses exceptions on bad JSON.request.json— a property that callsget_json()with default arguments. Raises a 400 error if the content type is notapplication/json.request.data— raw bytes of the request body. Use this when you need to parse the body yourself (e.g., a custom binary format or whenContent-Typeis not set).
For JSON APIs, always use request.get_json(silent=True) and handle the None case explicitly.
Testing with curl
curl -X POST http://localhost:5000/api/users \
-H "Content-Type: application/json" \
-d '{"name": "Alice", "email": "[email protected]"}'
Expected response (HTTP 201):
{"email": "[email protected]", "id": 1, "name": "Alice"}
Test a missing field:
curl -X POST http://localhost:5000/api/users \
-H "Content-Type: application/json" \
-d '{"name": "Alice"}'
Expected response (HTTP 400):
{"error": "Missing fields: ['email']"}
flask.abort() with Custom JSON Error Messages
By default, abort() returns an HTML error page — useless for a JSON API. The cleanest fix is a single errorhandler that catches all HTTPException subclasses and returns JSON instead.
from flask import Flask, jsonify, abort
from werkzeug.exceptions import HTTPException
app = Flask(__name__)
# Override default HTML error pages with JSON
@app.errorhandler(HTTPException)
def handle_http_exception(e):
return jsonify({
'error': e.name,
'message': e.description,
'status': e.code
}), e.code
@app.route('/api/items/<int:item_id>')
def get_item(item_id):
item = find_item(item_id) # your lookup function
if item is None:
abort(404) # triggers handle_http_exception → JSON response
if not item['is_public']:
abort(403, description="You do not have access to this item.")
return jsonify(item)
How abort() works
abort() raises a werkzeug.exceptions.HTTPException (a subclass of Exception). Flask catches it, looks for a registered error handler, and calls it. Registering on the base HTTPException class means one handler covers every HTTP status code.
When you call abort(403, description="..."), the description you supply replaces the default Werkzeug message and becomes the message field in your JSON response.
Handler for a specific status code
If you need different JSON shapes per status code, register individual handlers:
@app.errorhandler(404)
def not_found(e):
return jsonify({'error': 'Not found', 'hint': 'Check the ID in the URL'}), 404
Specific handlers take priority over the generic HTTPException handler.
Blueprint Structure for Larger APIs
Once an API grows beyond a handful of routes, split it into Blueprints. Each Blueprint owns a URL prefix and its own routes.
# blueprints/users.py
from flask import Blueprint, jsonify, request
users_bp = Blueprint('users', __name__, url_prefix='/api/users')
@users_bp.route('/', methods=['GET'])
def list_users():
return jsonify([])
@users_bp.route('/', methods=['POST'])
def create_user():
data = request.get_json(silent=True)
if not data:
return jsonify({'error': 'Invalid JSON'}), 400
# ... create logic
return jsonify({'id': 1, **data}), 201
# app.py
from flask import Flask
from blueprints.users import users_bp
app = Flask(__name__)
app.register_blueprint(users_bp)
if __name__ == '__main__':
app.run(debug=True)
A typical project layout:
project/
├── app.py
├── blueprints/
│ ├── __init__.py
│ ├── users.py
│ └── items.py
├── gunicorn.conf.py
└── requirements.txt
Input Validation
For most APIs, a simple validation function is enough without adding a heavy library like Marshmallow or Pydantic:
def validate_user_input(data):
errors = {}
if not data.get('email') or '@' not in data['email']:
errors['email'] = 'Valid email is required'
if not data.get('name') or len(data['name']) < 2:
errors['name'] = 'Name must be at least 2 characters'
return errors
@app.route('/api/users', methods=['POST'])
def create_user():
data = request.get_json(silent=True)
if not data:
return jsonify({'error': 'Invalid JSON'}), 400
errors = validate_user_input(data)
if errors:
return jsonify({'errors': errors}), 422
# proceed with valid data
user = {'id': 1, 'name': data['name'], 'email': data['email']}
return jsonify(user), 201
Use HTTP 422 Unprocessable Entity for validation failures — the request was well-formed JSON but semantically invalid. Reserve 400 for malformed requests (missing Content-Type, invalid JSON syntax).
If your API grows to dozens of fields or you want automatic OpenAPI docs, add marshmallow or use flask-smorest.
Running in Production with gunicorn
Flask's built-in dev server (flask run) is single-threaded and not safe for production. gunicorn is the standard WSGI server for Flask in production.
Basic start
gunicorn app:app
app:app means "the app object inside the app.py module".
Production command
gunicorn app:app \
--workers 4 \
--bind 0.0.0.0:8000 \
--timeout 120 \
--access-logfile /var/log/gunicorn/access.log \
--error-logfile /var/log/gunicorn/error.log
Workers formula: (2 × CPU cores) + 1. On a 2-core server, use 5 workers. Workers are separate OS processes — they don't share memory but can handle requests simultaneously.
gunicorn.conf.py
Put configuration in a file instead of passing flags every time:
bind = "0.0.0.0:8000"
workers = 5
worker_class = "sync"
timeout = 120
keepalive = 5
accesslog = "/var/log/gunicorn/access.log"
errorlog = "/var/log/gunicorn/error.log"
Start with the config file:
gunicorn -c gunicorn.conf.py app:app
systemd service
To keep gunicorn running and restart it on failure, create a systemd unit:
# /etc/systemd/system/myapi.service
[Unit]
Description=My Flask API
After=network.target
[Service]
User=www-data
WorkingDirectory=/var/www/myapi
ExecStart=/var/www/myapi/venv/bin/gunicorn -c gunicorn.conf.py app:app
Restart=always
[Install]
WantedBy=multi-user.target
Enable and start:
sudo systemctl enable myapi
sudo systemctl start myapi
sudo systemctl status myapi
Nginx Reverse Proxy for Flask/gunicorn
Running gunicorn directly on port 80 is not recommended. nginx sits in front of gunicorn and handles:
- SSL termination — nginx handles TLS, gunicorn only sees plain HTTP internally
- Static file serving — nginx serves static assets without touching Python
- Rate limiting — nginx can throttle clients before requests reach your app
- Buffering — nginx buffers slow clients so gunicorn workers are freed immediately
nginx configuration
server {
listen 80;
server_name api.yourdomain.com;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_read_timeout 120s;
}
}
Place this file at /etc/nginx/sites-available/myapi and enable it:
sudo ln -s /etc/nginx/sites-available/myapi /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Reading client IP in Flask
Because requests come through nginx, request.remote_addr will be 127.0.0.1. To get the real client IP, read the X-Forwarded-For header — but only trust it if you control the proxy. With Flask 3.x:
from flask import request
# Only safe behind a trusted proxy (your nginx)
client_ip = request.headers.get('X-Forwarded-For', request.remote_addr).split(',')[0].strip()
For a proper solution, use ProxyFix from Werkzeug:
from werkzeug.middleware.proxy_fix import ProxyFix
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
After this, request.remote_addr and request.scheme will reflect the real values from the client.
Testing Flask Endpoints
Flask's built-in test client makes writing tests straightforward — no running server needed.
import pytest
from app import app
@pytest.fixture
def client():
app.config['TESTING'] = True
with app.test_client() as client:
yield client
def test_create_user(client):
response = client.post('/api/users',
json={'name': 'Alice', 'email': '[email protected]'})
assert response.status_code == 201
data = response.get_json()
assert data['name'] == 'Alice'
def test_missing_fields(client):
response = client.post('/api/users', json={'name': 'Alice'})
assert response.status_code == 400
def test_invalid_json(client):
response = client.post('/api/users',
data='not json',
content_type='application/json')
assert response.status_code == 400
def test_abort_returns_json(client):
# Assuming GET /api/items/999 returns 404 via abort()
response = client.get('/api/items/999')
assert response.status_code == 404
data = response.get_json()
assert 'error' in data
Pass json= to client.post() and it automatically sets Content-Type: application/json and serializes the dict. Use response.get_json() to deserialize the response.
Run tests:
pip install pytest
pytest -v