Flask API Fundamentals · Flask Basics
Routes, Requests, and JSON
Read path, query string and JSON input in Flask, validate it before doing any work, and return the right status code.
Every API endpoint does the same three things: it reads input from the request, checks that the input makes sense, and returns a response with the right status code. Flask gives you a small set of tools for each step. This lesson shows where input comes from, how to validate it before doing any work, and the mistakes that cause confusing bugs.
Where input comes from
A request can carry data in four places. Flask exposes each one separately:
| Place | Example | How to read it in Flask |
|---|---|---|
| Path | /users/42 |
A route variable such as <int:user_id> |
| Query string | /users?page=2 |
request.args.get("page", 1, type=int) |
| JSON body | {"name": "Ada"} |
request.get_json(silent=True) |
| Headers | Authorization: Bearer ... |
request.headers.get("Authorization") |
Use the path for which resource, the query string for how to list or filter it, and the body for the data you are creating or changing.
Path parameters and converters
Converters turn part of the URL into a typed value. If the value doesn't match, Flask returns 404 before your function runs, so you never see /users/abc in a handler that expects a number.
from flask import Flask, jsonify
app = Flask(__name__)
USERS = {1: "Ada", 2: "Linus"}
@app.get("/users/<int:user_id>")
def get_user(user_id):
name = USERS.get(user_id)
if name is None:
return jsonify(error="user not found"), 404
return jsonify(id=user_id, name=name)
The built-in converters are string (the default), int, float, path (allows slashes) and uuid.
Query strings with defaults
request.args.get takes a default and a type. If the conversion fails, you get the default instead of an exception, which makes paging parameters safe to read:
from flask import request
@app.get("/users")
def list_users():
page = request.args.get("page", 1, type=int)
per_page = min(request.args.get("per_page", 20, type=int), 100) # cap it
return jsonify(page=page, per_page=per_page, items=[])
Always cap values like per_page. Otherwise one request for per_page=1000000 can load your whole table.
JSON bodies: validate before you do work
Read the body with request.get_json(silent=True). With silent=True it returns None instead of raising when the body is missing, isn't JSON, or was sent without a JSON content type. Then check the fields you need, and return every problem at once so the client can fix them in one go:
@app.post("/users")
def create_user():
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
return jsonify(error="send a JSON object"), 400
errors = {}
name = payload.get("name")
if not isinstance(name, str) or not name.strip():
errors["name"] = "name is required"
age = payload.get("age")
if age is not None and (not isinstance(age, int) or isinstance(age, bool) or age < 0):
errors["age"] = "age must be a whole number of 0 or more"
if errors:
return jsonify(error="validation failed", fields=errors), 422
return jsonify(id=3, name=name.strip(), age=age), 201
The validation logic is plain Python, so you can try it without a server. Edit the payloads and run it:
def validate_user(payload):
if not isinstance(payload, dict):
return 400, {"error": "send a JSON object"}
errors = {}
name = payload.get("name")
if not isinstance(name, str) or not name.strip():
errors["name"] = "name is required"
age = payload.get("age")
if age is not None and (not isinstance(age, int) or isinstance(age, bool) or age < 0):
errors["age"] = "age must be a whole number of 0 or more"
if errors:
return 422, {"error": "validation failed", "fields": errors}
return 201, {"name": name.strip(), "age": age}
for payload in [{"name": "Ada", "age": 36}, {"name": " "}, {"name": "Linus", "age": "30"}, ["not", "an", "object"]]:
print(validate_user(payload))
Output
(201, {'name': 'Ada', 'age': 36})
(422, {'error': 'validation failed', 'fields': {'name': 'name is required'}})
(422, {'error': 'validation failed', 'fields': {'age': 'age must be a whole number of 0 or more'}})
(400, {'error': 'send a JSON object'})Notice the third payload: "30" is a string, not a number. JSON keeps types, and so should your API. Converting silently hides bugs in the client.
Returning the right status code
Return a tuple (body, status) whenever the answer isn't 200 OK:
| Status | Use it when |
|---|---|
200 OK |
A read or update succeeded |
201 Created |
A new resource was created (optionally add a Location header) |
204 No Content |
Success with nothing to return, such as a delete |
400 Bad Request |
The body can't be read at all |
404 Not Found |
The resource in the path doesn't exist |
422 Unprocessable Content |
The body is readable JSON, but the values are invalid |
Common mistakes
- Using
request.jsonwithout a fallback. In current Flask it raises an error when the client forgets theContent-Type: application/jsonheader, so the client gets an HTML error page.get_json(silent=True)lets you return your own JSON error. - Trusting the shape of the body. A client can send a list, a string or
null. Check that it's a dict before calling.get(). - Treating
Trueas a number. In Pythonboolis a subclass ofint, soisinstance(True, int)isTrue. Exclude it when you validate numbers. - Returning
200for everything. Clients and monitoring tools rely on the status code. An error with200looks like success.
Try it
Add a PATCH /users/<int:user_id> route that accepts a JSON body with an optional name. Return 404 for an unknown user, 422 when name is present but empty, and the updated user otherwise.
Open in the app to mark lessons complete, take notes and track your progress.