}

Flask REST API Complete Guide 2026 — POST, abort, gunicorn, nginx

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. Returns None if parsing fails (unless silent=False). Accepts optional arguments: force=True skips the Content-Type check, silent=True suppresses exceptions on bad JSON.
  • request.json — a property that calls get_json() with default arguments. Raises a 400 error if the content type is not application/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 when Content-Type is 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

Related Flask Guides