Building REST APIs with Python: A Practical Guide

The design decisions behind a REST API that other programs can depend on: resource URLs, what GET, POST, PUT and DELETE promise, the status codes that matter, validation at the boundary, one error format, and pagination. Shown with a small Flask API exercised through its test client.

GloryTecks Team

Training & Placement Faculty

6 May 2026 6 min read

Last updated: 24 September 2026

Share:

An API is a promise to people you may never meet: the front-end developer, the mobile app, the partner's integration script. They will write code against every URL, status code and error message you return, and they will break when you change them. So the design decisions matter more than the framework. This guide covers the decisions, framework-independent, then shows them in a small Flask API you can run.

Resources and URLs

REST organises an API around resources, the nouns of your domain, each with a URL. Operations are expressed with HTTP methods on those URLs, not with verbs in the path:

Instead ofUse
POST /createTaskPOST /api/v1/tasks
GET /getTask?id=42GET /api/v1/tasks/42
POST /tasks/42/deleteDELETE /api/v1/tasks/42
GET /tasksForUser?user=7GET /api/v1/users/7/tasks

Use plural nouns for collections, keep nesting to one level (/users/7/tasks, not /users/7/projects/3/tasks/42), and put a version in the path (/v1) from the start. It is the cheapest way to change things later without breaking existing clients.

What each method promises

HTTP methods carry guarantees that clients, proxies and retry logic rely on (RFC 9110: HTTP semantics). Safe methods do not change anything on the server. Idempotent methods can be repeated with the same effect as doing them once, so a client can retry them after a timeout:

MethodUse it toSafeIdempotent
GETRead a resource or collectionYesYes
POSTCreate a resource, or trigger an actionNoNo
PUTReplace a resource with the body you sendNoYes
PATCHChange some fields of a resourceNoNot necessarily
DELETERemove a resourceNoYes

PATCH is defined in a separate specification, RFC 5789, and is not idempotent by definition. The practical consequence: a GET endpoint must never modify data, and a POST that times out may or may not have created the resource. APIs that take payments or orders often accept an idempotency key header on POST, so that a retried request is recognised and not processed twice.

Status codes worth getting right

A client's code branches on the status code before it reads the body. You do not need dozens; you need these used consistently:

CodeMeaningTypical case
200 OKSuccess, with a bodyGET, or an update that returns the resource
201 CreatedA resource was createdPOST, with a Location header pointing at it
204 No ContentSuccess, nothing to returnDELETE
400 Bad RequestThe request is malformedUnparseable JSON
401 UnauthorizedNot authenticatedMissing or invalid token
403 ForbiddenAuthenticated, not allowedA user editing someone else's task
404 Not FoundNo such resourceUnknown ID
409 ConflictConflicts with current stateCreating a duplicate username
422 Unprocessable ContentWell-formed but invalidA title that is empty
500 Internal Server ErrorYour bugNever return it on purpose

Note the 401 and 403 distinction, which is often blurred: 401 means "who are you?", 403 means "I know who you are, and no".

One error format everywhere

Clients should be able to handle every error with one piece of code. RFC 9457 defines a standard shape for that, served with the media type application/problem+json: a title, the status, a human-readable detail, and optional fields of your own (RFC 9457: problem details). Adopting it means you never debate the error format again.

A small Flask implementation

The API below manages tasks, keeping data in a dictionary so the example stays self-contained. It applies each rule above: versioned resource URLs, validation before anything is stored, 201 with a Location header, 204 on delete, problem-details errors, and limit/offset pagination (Flask quickstart).

python
from flask import Flask, jsonify, request

app = Flask(__name__)
TASKS: dict[int, dict] = {}
NEXT_ID = 1

def problem(status: int, title: str, detail: str):
    body = {"type": "about:blank", "title": title, "status": status, "detail": detail}
    return jsonify(body), status, {"Content-Type": "application/problem+json"}

@app.get("/api/v1/tasks")
def list_tasks():
    limit = min(request.args.get("limit", default=20, type=int), 100)
    offset = request.args.get("offset", default=0, type=int)
    items = list(TASKS.values())
    return {"items": items[offset:offset + limit], "total": len(items), "limit": limit, "offset": offset}

@app.post("/api/v1/tasks")
def create_task():
    global NEXT_ID
    data = request.get_json(silent=True)
    if not isinstance(data, dict):
        return problem(400, "Bad Request", "Body must be a JSON object.")
    title = str(data.get("title", "")).strip()
    if not title or len(title) > 200:
        return problem(422, "Invalid task", "title is required and must be 1-200 characters.")
    task = {"id": NEXT_ID, "title": title, "done": False}
    TASKS[NEXT_ID] = task
    NEXT_ID += 1
    return task, 201, {"Location": f"/api/v1/tasks/{task['id']}"}

@app.get("/api/v1/tasks/<int:task_id>")
def get_task(task_id: int):
    if task_id not in TASKS:
        return problem(404, "Not Found", f"No task with id {task_id}.")
    return TASKS[task_id]

@app.delete("/api/v1/tasks/<int:task_id>")
def delete_task(task_id: int):
    if TASKS.pop(task_id, None) is None:
        return problem(404, "Not Found", f"No task with id {task_id}.")
    return "", 204

Flask's test client sends requests to the app without starting a server, which is also how you would test it (Flask: testing):

python
client = app.test_client()

r = client.post("/api/v1/tasks", json={"title": "Write API docs"})
print(r.status_code, r.headers["Location"], r.get_json())

r = client.post("/api/v1/tasks", json={"title": "   "})
print(r.status_code, r.content_type, r.get_json()["detail"])

client.post("/api/v1/tasks", json={"title": "Add pagination"})
r = client.get("/api/v1/tasks?limit=1&offset=1")
print(r.status_code, r.get_json())

print(client.delete("/api/v1/tasks/1").status_code, client.get("/api/v1/tasks/1").status_code)
text
201 /api/v1/tasks/1 {'done': False, 'id': 1, 'title': 'Write API docs'}
422 application/problem+json title is required and must be 1-200 characters.
200 {'items': [{'done': False, 'id': 2, 'title': 'Add pagination'}], 'limit': 1, 'offset': 1, 'total': 2}
204 404

Decisions to make before you ship

  • Validation at the boundary. Check every field before anything touches storage, and report all problems in the 422 body. Libraries such as Pydantic turn a class definition into that validation; see Pydantic explained.
  • Pagination on every list. Return a page, never the whole table. Limit/offset is simple. Cursor pagination (return a next token) stays correct when rows are inserted while a client is paging, at the cost of not jumping to page 50.
  • A real database. Swap the dictionary for a database layer with transactions; the SQLAlchemy tutorial covers one.
  • Authentication over HTTPS only, usually with bearer tokens, and authorisation checks on every object a request touches, not just on login.
  • Documentation clients can test against. An OpenAPI description, whether written by hand or generated, is the contract. Frameworks such as FastAPI generate it from your code; the FastAPI guide builds the same kind of API that way.

Changing an API without breaking clients

Once clients depend on an API, some changes are safe and some are not. Adding a new endpoint, a new optional request field or a new field in a response is usually safe, because well-written clients ignore fields they do not know. Removing or renaming a field, changing its type, making an optional field required, or changing what a status code means will break someone. Make breaking changes in a new version (/api/v2/...), keep the old version running for an announced period, and tell clients when it will be switched off. Documenting which changes you consider non-breaking lets client developers write code that tolerates them.

Choosing a framework

The rules above hold in any framework. Flask keeps things minimal, as shown. FastAPI adds validation and API docs from type hints. Django REST framework sits on Django's ORM, admin and authentication. Flask vs FastAPI vs Django compares them for different kinds of projects.

REST API development with Flask is part of the web development module in the Python Programming course.

Sources

GloryTecks Team

Training & Placement Faculty

The GloryTecks faculty team mentors learners across Data Science, AI and Data Engineering, with a focus on real-time projects and placement readiness in Hyderabad.

Want to master Python?

Join GloryTecks’ expert-led Python Programming training in Hyderabad with real-time projects and 100% placement support.

Explore course