Python REST APIs | Build API Servers with Flask and Django

Key takeaways

Design RESTful APIs in Python: HTTP verbs, resource URLs, Flask CRUD examples, Django REST Framework, JWT auth, error handling, and production-minded tips.

Introduction

REST (Representational State Transfer) is the architectural style that underlies the vast majority of HTTP APIs written today, and Python — through Flask’s minimalism and Django REST Framework’s (DRF) batteries-included approach — covers both ends of the spectrum for building one. This article is not just a syntax reference for @app.route and ModelViewSet. It walks through why REST conventions exist, where Flask and DRF genuinely differ in philosophy, and where naive implementations quietly introduce security and scalability problems that only show up once real traffic hits the API.

Two ideas sit at the center of REST that are easy to skip past when copying boilerplate:

  1. Statelessness. Every request must carry everything the server needs to process it — authentication, identifying parameters, the works. The server does not remember the client between requests. This is what makes REST APIs horizontally scalable: any server instance behind a load balancer can handle any request, because no server is holding session state that only it knows about. If your API design requires “call /login first, then the server remembers you for the next five requests” without encoding that state in a token the client resends, you have broken statelessness and made horizontal scaling harder than it needs to be.
  2. Resource orientation. URLs identify things (resources: users, posts, orders), not actions. The HTTP verb — not the URL — expresses the action. GET /api/users/getUserById?id=1 is not RESTful; GET /api/users/1 is. This distinction is not pedantry: it’s what makes an API predictable enough that a client developer can guess the shape of an endpoint they haven’t used yet, and it’s what lets HTTP infrastructure (caches, proxies, API gateways) reason about your traffic correctly, since GET is defined as safe and cacheable while POST is not.

The most common real-world violation of REST principles is what’s sometimes called “the 200 OK anti-pattern”: returning HTTP 200 for every response, including failures, and putting the actual error state inside a JSON body like {"success": false, "error": "not found"}. This defeats HTTP-level tooling — load balancer health checks, CDN caching rules, browser devtools network filtering, and API monitoring dashboards all key off the status code, not the body. A client library that checks response.ok (status in the 200–299 range) before even parsing JSON will silently treat your error as a success. Getting status codes right is cheap and pays for itself the first time someone has to debug production traffic from logs alone.

REST API fundamentals

RESTful design

Resource-oriented routing maps each HTTP verb onto a specific meaning for a given URL. The same base path (/api/users or /api/users/1) means something different depending only on the verb used against it:

Resource-oriented routes:
GET    /api/users       - list users
GET    /api/users/1     - get user 1
POST   /api/users       - create user
PUT    /api/users/1     - replace user 1
DELETE /api/users/1     - delete user 1

Notice the pattern: the collection endpoint (/api/users, plural, no ID) handles listing and creation, while the item endpoint (/api/users/1, with an ID) handles reading, replacing, and deleting a specific resource. Beginners often reach for PUT for partial updates, but strictly, PUT means “replace this resource entirely with what I’m sending” — any field you omit from the request body is semantically supposed to be cleared. PATCH is the verb for partial updates, where only the fields present in the body change. Many real-world APIs are loose about this distinction, but if you’re designing a new API, using PATCH for partial updates avoids surprising a client who sends a partial payload to PUT and unexpectedly wipes other fields.

The request lifecycle for a typical REST call — whether served by Flask or DRF — follows the same conceptual shape: the client sends a request with any required auth, the framework routes it to a handler, the handler validates input, touches the data layer, and serializes a response back into JSON with an appropriate status code.

sequenceDiagram
    participant C as Client
    participant R as Router
    participant A as Auth/Permission Check
    participant V as Validation Layer
    participant D as Data Store
    participant S as Serializer

    C->>R: HTTP request (verb + path + body)
    R->>A: Match route, check credentials
    alt Unauthenticated / Unauthorized
        A-->>C: 401 Unauthorized or 403 Forbidden
    else Authorized
        A->>V: Pass request through
        V->>V: Validate & coerce input
        alt Invalid input
            V-->>C: 400 Bad Request\n(field-level errors)
        else Valid input
            V->>D: Query / mutate resource
            D-->>S: Raw model data
            S->>S: Serialize to JSON,\nstrip internal fields
            S-->>C: 200/201/204 + JSON body
        end
    end

This diagram matters because every pitfall discussed later in this article corresponds to skipping one of these stages: skipping the auth check on a nested resource, skipping validation and trusting client input directly (mass assignment), or skipping serialization discipline and leaking internal fields like password hashes.

REST API with Flask

Flask is a micro-framework: it gives you routing, request/response objects, and a template engine, and deliberately leaves the rest — ORM, serialization, authentication, admin UI — up to you. This is a genuine trade-off, not just “less code.” It means small services stay small, but it also means the discipline of consistent error shapes, input validation, and serialization is entirely on you and your team’s conventions, not enforced by the framework.

CRUD API

from flask import Flask, jsonify, request
app = Flask(__name__)
users = [
    {'id': 1, 'name': 'Alice', 'email': '[email protected]'},
    {'id': 2, 'name': 'Bob', 'email': '[email protected]'}
]
# List (GET)
@app.route('/api/users', methods=['GET'])
def get_users():
    return jsonify({'users': users, 'count': len(users)})
# Single resource (GET)
@app.route('/api/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:
        return jsonify(user)
    return jsonify({'error': 'User not found'}), 404
# Create (POST)
@app.route('/api/users', methods=['POST'])
def create_user():
    data = request.get_json()
    
    if not data or 'name' not in data or 'email' not in data:
        return jsonify({'error': 'Invalid data'}), 400
    
    new_user = {
        'id': max(u['id'] for u in users) + 1 if users else 1,
        'name': data['name'],
        'email': data['email']
    }
    users.append(new_user)
    return jsonify(new_user), 201
# Update (PUT)
@app.route('/api/users/<int:user_id>', methods=['PUT'])
def update_user(user_id):
    user = next((u for u in users if u['id'] == user_id), None)
    if not user:
        return jsonify({'error': 'User not found'}), 404
    
    data = request.get_json()
    user.update(data)
    return jsonify(user)
# Delete (DELETE)
@app.route('/api/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
if __name__ == '__main__':
    app.run(debug=True)

A few details worth dwelling on. First, get_user returns 404 with a JSON error body when the lookup fails, rather than letting Flask’s default HTML 404 page leak through — API clients should never have to parse HTML to detect an error. Second, create_user returns 201 Created, not 200 OK. The distinction matters to any client or tooling that inspects status codes: 201 tells the caller “a new resource now exists,” which is meaningfully different from “your request succeeded but nothing new was created.” Third, delete_user returns 204 No Content with an empty body — by convention, a successful delete has nothing left to return, so sending back 200 with an empty JSON object is technically valid but wastes a status code that already communicates “success, nothing to say.”

Now the part this snippet glosses over, deliberately, for brevity: update_user’s user.update(data) line is a textbook example of a mass assignment vulnerability. It merges whatever keys the client sent directly into the stored record with no allowlist. If the users dictionary held an is_admin field, a client could PUT /api/users/1 with {"name": "Alice", "is_admin": true} and silently grant itself admin rights, because nothing checks which fields the client is allowed to modify versus which fields exist on the model. The fix is always the same shape regardless of framework: explicitly allowlist which fields a given endpoint may write (for field in ('name', 'email'): if field in data: user[field] = data[field]), rather than trusting the full shape of the incoming JSON. This is one of the most common real-world API vulnerabilities, and it’s invisible in demo code precisely because demo records rarely include a field worth protecting.

Also worth calling out: this example stores users in a plain Python list, so it resets every time the process restarts and cannot be shared across multiple worker processes (which is exactly what you’d run in production behind Gunicorn). It’s fine for learning the routing and status-code conventions covered here, but a real deployment needs a persistent data store — that’s the subject of the next post in this series.

Flask vs. Django REST Framework: choosing deliberately

This choice is one of the most consequential early decisions in a Python API project, and it’s worth making deliberately rather than defaulting to whichever framework you learned first.

Reach for Flask (or FastAPI, for that matter) when:

  • The service is small and focused — a handful of endpoints, maybe a single resource type.
  • You want full control over the shape of responses, error handling, and middleware, without fighting a framework’s opinions.
  • You’re building a microservice where “batteries included” means unused dependencies and unnecessary abstraction layers.
  • The team is comfortable assembling its own conventions (and disciplined enough to actually keep them consistent — this is the real cost of “flexibility”).

Reach for Django REST Framework when:

  • The project already uses Django, or will grow to need Django’s ORM, admin panel, and migrations system.
  • You expect many resources with overlapping CRUD patterns — DRF’s ModelViewSet and routers eliminate huge amounts of repetitive boilerplate that you’d otherwise hand-write per resource in Flask.
  • You need consistent, declarative permission and authentication handling across dozens of endpoints — DRF’s permission classes compose predictably; hand-rolled Flask decorators tend to drift in behavior across a codebase as different developers write “similar but not identical” checks.
  • Built-in browsable API, consistent pagination, and serializer-driven validation save real time as the surface area grows.

The trade-off in one sentence: Flask costs you upfront structure and gives you flexibility later; DRF costs you a learning curve and some rigidity upfront and gives you consistency and less repeated code later. Neither is “more correct” — a five-endpoint internal tool built with DRF’s ceremony is over-engineered, and a fifty-model public API hand-rolled in bare Flask usually degrades into inconsistent, under-tested endpoints as more people touch it.

Django REST Framework

Installation

pip install djangorestframework

Serializers

# blog/serializers.py
from rest_framework import serializers
from .models import Post
class PostSerializer(serializers.ModelSerializer):
    class Meta:
        model = Post
        fields = ['id', 'title', 'content', 'author', 'created_at']
        read_only_fields = ['id', 'created_at']

This is the piece that directly solves the mass assignment problem shown earlier. A DRF serializer is simultaneously three things: a validation layer (rejecting malformed input before it ever reaches your database), a field allowlist (only title, content, and author are writable — anything else in the request body is silently ignored, not merged in), and a shape contract for the JSON representation of the model in both directions. read_only_fields explicitly blocks the client from setting id or created_at themselves, which is exactly the kind of allowlisting the raw user.update(data) line in the Flask example was missing. This is the core argument for a dedicated serialization layer over .update()-style shortcuts: it makes “which fields can a client write” an explicit, reviewable declaration instead of an implicit property of whatever happens to be in the client’s JSON payload.

ViewSet

# blog/views.py
from rest_framework import viewsets
from .models import Post
from .serializers import PostSerializer
class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer

A ModelViewSet is DRF’s biggest boilerplate reduction: this five-line class alone implements list, retrieve, create, update, partial-update, and destroy — the entire CRUD surface that took roughly thirty lines to hand-write in the Flask example above. The trade-off is that the behavior is now implicit — a developer reading this file has to already know DRF’s conventions to understand what routes exist and what each one does, whereas the Flask version is fully visible in the source. This is the concrete version of the “control vs. convention” trade-off described in the previous section. Note also that queryset = Post.objects.all() with no further filtering means any authenticated (or, if permissions aren’t set, any) client can list every post in the table — production code almost always narrows this with .filter() based on the requesting user, and adds pagination (covered in Section 7) so a table with millions of rows doesn’t get serialized into a single multi-megabyte response.

URL routing

# blog/urls.py
from rest_framework.routers import DefaultRouter
from .views import PostViewSet
router = DefaultRouter()
router.register(r'posts', PostViewSet)
urlpatterns = router.urls

DefaultRouter inspects the ModelViewSet and generates the full set of resource-oriented URLs automatically — GET/POST /posts/, GET/PUT/PATCH/DELETE /posts/{id}/ — following exactly the same resource-vs-item URL convention introduced in Section 1. This is worth noting explicitly: the router isn’t inventing a new pattern, it’s mechanically generating the same pattern you’d hand-write with @app.route decorators in Flask, just derived from the viewset’s declared actions instead of typed out per-endpoint.

Authentication, permissions, and common security pitfalls

JWT authentication

pip install djangorestframework-simplejwt
# settings.py
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ],
}
# urls.py
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
)
urlpatterns = [
    path('api/token/', TokenObtainPairView.as_view()),
    path('api/token/refresh/', TokenRefreshView.as_view()),
]

JWT (JSON Web Token) fits REST’s statelessness requirement directly: the token itself carries the claims the server needs (user ID, expiry, sometimes roles), so any server instance can verify it without a shared session store. TokenObtainPairView issues an access token (short-lived, sent with every request) and a refresh token (longer-lived, used only to mint new access tokens) — the split exists so that a leaked access token has a short blast radius, while the refresh token, used far less often, can be more tightly controlled (HttpOnly cookie, rotation on use, revocation list).

Beyond wiring up the token endpoints, three security pitfalls come up repeatedly in real REST APIs, and none of them are prevented automatically by adding authentication:

  • Missing auth on nested resources. It’s common to correctly protect GET /api/posts/1 but forget that GET /api/posts/1/comments or DELETE /api/posts/1/comments/5 needs its own permission check — and specifically, needs to verify the requesting user owns or is allowed to touch that specific nested object, not just that they’re logged in. A classic bug: user A is authenticated and allowed to hit the comments endpoint in general, but the handler never checks that comment 5 actually belongs to a post user A has access to, letting any authenticated user delete any comment by ID. Every nested route needs the same “is this specific object accessible to this specific user” check as the top-level route, not just a blanket IsAuthenticated.
  • CORS misconfiguration. A very common shortcut during development is Access-Control-Allow-Origin: * combined with credentialed requests (cookies, Authorization headers). Browsers actually forbid the wildcard when credentials are involved, but developers work around this by reflecting whatever Origin header the browser sent back verbatim — which functionally reopens the same hole and lets any website’s frontend make authenticated requests to your API on behalf of a logged-in user. The fix is an explicit allowlist of known frontend origins, never a reflected or wildcarded one, especially on any endpoint reachable with cookies or bearer tokens.
  • Trusting client-supplied identifiers for authorization. If a request body includes {"user_id": 5, ...} and the handler uses that user_id to decide whose data to write instead of deriving it from the authenticated session/token, any client can act as any other user simply by changing a number in the JSON body. The authenticated identity from the token — never a value pulled out of the request body — should be the source of truth for “who is this action happening as.”

Error handling

Custom JSON errors

from flask import jsonify
@app.errorhandler(404)
def not_found(error):
    return jsonify({
        'error': 'Not Found',
        'message': 'The requested resource could not be found'
    }), 404
@app.errorhandler(500)
def internal_error(error):
    return jsonify({
        'error': 'Internal Server Error',
        'message': 'An unexpected server error occurred'
    }), 500

Flask’s @app.errorhandler intercepts framework-level and unhandled exceptions and rewrites them into the same JSON error shape used everywhere else in the API — this consistency is the actual point, more than the specific fields chosen. A client integrating against your API should be able to write one error-parsing code path (check status code, read error and message) that works for every endpoint, instead of special-casing each route’s ad hoc error format. Notice the 500 handler deliberately returns a generic message rather than the underlying exception text or stack trace — leaking internal exception details (database connection strings, file paths, library versions) in a 500 response body is a minor but real information-disclosure risk, and it’s worth confirming debug=True (visible in the earlier app.run() calls) is only ever set for local development, never in production, since Flask’s debug mode will happily render a full interactive traceback to any client that triggers a server error.

Pagination and filtering for list endpoints

Every collection endpoint shown so far (GET /api/users, GET /api/posts) has one structural weakness in common: it returns the entire table in one response. That’s harmless with two seed records; it’s a production incident waiting to happen once the table has 500,000 rows — a single request would serialize hundreds of megabytes of JSON, block a worker thread for the duration, and likely time out or exhaust memory before the client ever sees a response.

Pagination is the standard fix, and it needs to be a first-class part of a list endpoint’s contract, not an afterthought. Two common approaches:

  • Offset/limit (or page/per_page) pagination — simple to implement and reason about, but degrades on very large offsets (the database still has to scan past all the skipped rows) and can show duplicate or skipped items if rows are inserted or deleted between page requests.
  • Cursor-based pagination — uses an opaque token derived from the last-seen row (commonly its sort key) instead of a numeric offset, avoiding the “shifting data” problem and scaling better on large tables, at the cost of not letting a client jump directly to “page 40.”

For most internal or moderate-traffic APIs, offset/limit pagination (as used in Section 8’s example, via page and per_page query parameters) is the pragmatic default. Filtering follows the same principle of explicitness: query parameters like ?status=published&author=5 should map to an explicit, allowlisted set of filterable fields on the server — never to a raw pass-through of arbitrary query params into a database query, which risks both incorrect results (unfiltered params silently ignored, or worse, misapplied) and, in careless implementations, injection-style bugs if a query param is concatenated directly into a raw SQL string instead of going through the ORM’s parameterized filtering.

Practical example

Full Flask API sketch

from flask import Flask, jsonify, request
from functools import wraps
app = Flask(__name__)
# Simple API key check
API_KEY = "secret-key-123"
def require_api_key(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        api_key = request.headers.get('X-API-Key')
        if api_key != API_KEY:
            return jsonify({'error': 'Unauthorized'}), 401
        return f(*args, **kwargs)
    return decorated
# In-memory store instead of a database
posts = []
@app.route('/api/posts', methods=['GET'])
def get_posts():
    page = request.args.get('page', 1, type=int)
    per_page = request.args.get('per_page', 10, type=int)
    
    start = (page - 1) * per_page
    end = start + per_page
    
    return jsonify({
        'posts': posts[start:end],
        'page': page,
        'total': len(posts)
    })
@app.route('/api/posts', methods=['POST'])
@require_api_key
def create_post():
    data = request.get_json()
    
    required_fields = ['title', 'content']
    if not all(field in data for field in required_fields):
        return jsonify({'error': 'Missing required fields'}), 400
    
    post = {
        'id': len(posts) + 1,
        'title': data['title'],
        'content': data['content'],
        'author': data.get('author', 'Anonymous')
    }
    posts.append(post)
    
    return jsonify(post), 201
if __name__ == '__main__':
    app.run(debug=True)

This example ties the earlier sections together end to end. get_posts implements the offset/limit pagination pattern from Section 7 directly — start/end slicing on the in-memory list stands in for what would be a LIMIT/OFFSET clause (or .offset().limit() in an ORM) against a real database, and the response deliberately includes page and total alongside the data so a client can compute whether more pages exist without a second request. create_post demonstrates the require_api_key decorator pattern — Flask’s equivalent of DRF’s declarative permission classes, just hand-rolled with functools.wraps to preserve the wrapped function’s metadata (name, docstring) for introspection and tooling.

It’s worth being direct about this example’s limits rather than presenting it as production-ready: a single static API_KEY string, compared with == and shared across every client, is meaningfully weaker than per-client keys or JWTs — it can’t be revoked for one client without breaking all of them, it doesn’t identify which client made a request (useful for auditing and rate limiting), and a naive == string comparison is technically vulnerable to timing attacks (use hmac.compare_digest if you do stick with raw key comparison). It’s a reasonable pattern for a quick internal tool or a demo; a public-facing API should move to per-client keys or a proper token scheme like the JWT setup shown in Section 5.

Designing the REST API

API design best practices

# Use nouns, not verbs
GET /api/users  # OK
GET /api/getUsers  # Avoid
# Prefer plural collection names
GET /api/users  # OK
GET /api/user  # Avoid
# Hierarchical resources
GET /api/users/1/posts  # posts belonging to user 1
# Meaningful HTTP status codes
200 OK           # success
201 Created      # created
400 Bad Request  # bad input
401 Unauthorized # auth required
404 Not Found    # missing resource
500 Server Error # server failure

These conventions exist to make an API learnable without reading its documentation first — a developer who already knows REST conventions can correctly guess that DELETE /api/users/1/posts/5 deletes post 5 belonging to user 1, without ever opening your API reference. Two status codes deserve special mention because they’re frequently confused: 401 Unauthorized actually means “you are not authenticated — no valid credentials were presented,” while 403 Forbidden means “you are authenticated, but you don’t have permission for this specific action.” Returning 401 when you mean 403 (or vice versa) misleads client error handling — a client seeing 401 will typically try to re-authenticate or refresh a token, which is pointless and will loop forever if the real problem is a permissions issue that re-authenticating can’t fix.

Next in the series



Frequently Asked Questions (FAQ)

Q. Should an update endpoint use PUT or PATCH?

A. Use PUT when the client sends the complete representation of the resource and the server replaces it; fields left out are expected to be cleared or reset, and repeating the same request gives the same result. Use PATCH when the client sends only the fields to change, which is what most edit forms and mobile clients actually do. In Django REST Framework, ModelViewSet supports both: PATCH maps to partial_update, which validates with partial=True, so required fields may be omitted.