Building a JSON API with Flask
Everything you've built so far hands back HTML. A view runs render_template, the browser gets a page, a human reads it. That's the right shape when the consumer is a person looking at a screen. But notes don't only get read by people — a mobile app might want them, a React frontend might fetch them, another service might sync them. None of those wants a styled HTML page; they want the raw data. They want JSON.
Here's the mental model to carry in before any code. 📝 An API and a web page are the same Flask app wearing two different hats. Same routing, same view functions, same request cycle — the only thing that changes is what a view returns. Return render_template(...) and you've served a page for a human. Return jsonify(...) and you've served data for a program. Flask was never "a web-page framework" — it's a request-to-response framework, and JSON is just another kind of response. This phase swaps the hat.
If "GET, POST, paths, status codes, resources" aren't yet second nature, read REST APIs explained alongside this — it's the vocabulary the whole phase leans on.
HTML for humans, JSON for programs
The split is worth making concrete. So far a note route looked like this:
=
return
What just happened: this fetches a Note (the model from Phase 5) and renders it into an HTML template — a full page with markup, styling, the works. Perfect for a browser, useless to a program that just wants the title and content as data.
The API version of that same idea returns the note as data:
=
return
What just happened: same fetch, different return. jsonify(...) takes a Python dict, serializes it to a JSON string, and — this is the part people forget it does for you — sets the Content-Type: application/json header so the client knows what it's receiving. The two routes can happily coexist in one app: /notes/<id> serves a page, /api/notes/<id> serves the data. 💡 The /api prefix is just a convention to keep the two worlds visually separate; Flask doesn't treat it specially.
JSON endpoints: reading and writing data
📝 Flask has no built-in serializer. It won't magically turn a Note object into JSON — jsonify(note) would fail, because Flask doesn't know which fields you want or how to format them. You decide the shape, by converting the model to a plain dict yourself. A small helper keeps that in one place:
return
What just happened: one function that defines your API's note shape. Every endpoint that returns a note goes through it, so the JSON stays consistent and you change the format in exactly one spot. This hand-serialization is the price of Flask's minimalism — and later we'll see the extension that automates it.
Now the read-the-collection endpoint:
=
return
What just happened: Note.query.all() pulls every note as objects, the list comprehension runs each through note_to_dict, and jsonify serializes the resulting list. A client hitting this gets:
GET /api/notes
What just happened: a clean JSON array, ready for any program to parse. No HTML, no styling — just the data.
Creating a note is the interesting direction, because now data flows in. A browser form sends request.form, but an API client sends a JSON body, and you read that with request.get_json():
=
=
return , 201
What just happened: request.get_json() parses the incoming JSON body into a Python dict — the API-client counterpart to request.form. You build a Note from it, then add + commit exactly as in Phase 5 (the database habits don't change just because the input is JSON). The return is a tuple: the created note as JSON, plus 201. That second value is a status code, and it matters more than it looks.
A client creating a note sends and receives:
POST /api/notes
Content-Type: application/json
{"title": "Read more", "content": "finish the Flask guide"}
What just happened: the client posts a JSON body, your view reads it, saves it, and echoes back the created note — now with its real id filled in by the database. That round-trip — send data, get the saved version back — is the bread and butter of a REST API.
Status codes and JSON errors
The number a response carries is half its meaning. 200 OK says "here's what you asked for." 201 Created says "I made the thing you posted." 404 Not Found says "no such note." A client reads these — your mobile app checks the status before it trusts the body. Returning the right one is not optional politeness; it's part of the contract. (The HTTP & JSON API basics guide is the cheat sheet for which code means what.)
Returning a code is just the second item in a return tuple, as you saw with 201. For a deliberate "not found," Flask gives you abort:
=
return
What just happened: abort(404) immediately stops the view and triggers Flask's 404 handling — no return needed, no risk of running the rest of the function on a None. (get_or_404 from Phase 5 is the one-liner version of this exact pattern.)
But here's the trap. ⚠️ Flask's default error responses are HTML pages, not JSON. Out of the box, abort(404) sends back a little HTML document — fine for a browser, but a JSON client trying to JSON.parse an HTML page gets a parse error and no idea what went wrong. An API must answer errors in the same language as everything else: JSON. You fix this by registering an error handler:
return , 404
What just happened: @app.errorhandler(404) tells Flask "when a 404 happens anywhere in the app, run this instead of the default HTML page." Now every 404 — from abort(404), get_or_404, or an unmatched route — comes back as {"error": "Not found"} with the right status code. Your clients get consistent, parseable errors. Register the same kind of handler for 400 (bad input) and 500 (server error) and your API speaks one language end to end.
Structuring an API as it grows
Two endpoints fit fine in one file. Twenty do not. The tool for that is the blueprint from Phase 6 — group all your /api routes into one blueprint and register it under a shared prefix:
=
return
What just happened: the blueprint collects every API route under /api automatically — define @api.route("/notes") and it lives at /api/notes. Your API becomes one self-contained module you can read, test, and reason about on its own, cleanly separated from the HTML side of the app.
And the hand-serialization? That's where Flask's extension philosophy shows up again. 💡 The same "small core + chosen extensions" pattern you saw with Flask-SQLAlchemy applies to APIs: marshmallow (or Flask-Marshmallow) defines schemas that serialize models to JSON and validate incoming data, killing the boilerplate of note_to_dict and manual data["title"] lookups. Flask-RESTful offers a class-based structure for organizing resources. You don't need either to ship an API — but as it grows, reaching for one is the natural next step, exactly as you reached for Flask-SQLAlchemy when a Python list stopped being enough.
Flask vs FastAPI for APIs, honestly
You can absolutely build a real, production API with Flask — plenty of teams do, and you just saw how. But honesty matters more than loyalty here, so: 💡 FastAPI was built for APIs in a way Flask wasn't.
Flask is a general web framework that does HTML and JSON equally well, with APIs as one thing among many. FastAPI made the API the whole point, and it shows. The things you did by hand this phase — writing note_to_dict, reading and validating request.get_json(), registering JSON error handlers, and (in a real app) documenting every endpoint — FastAPI does automatically from your type hints. One typed function declaration becomes validation, serialization, and interactive /docs, all kept in sync. No note_to_dict, no manual validation, no separately-maintained docs that drift from the code.
So the honest dividing line, with no tribalism:
- Reach for Flask APIs when you're already in a Flask app and want to add a JSON endpoint or two, when you want full manual control over every response, or when the API is a side of a larger HTML app.
- Reach for FastAPI when the API is the product — a backend serving a frontend or mobile app, a microservice, anything where validation and documentation carry real weight and you'd rather not hand-roll them.
Neither is "better." Flask trades automation for control and simplicity; FastAPI trades a stricter, type-hint-driven style for a huge amount of automation. Pick by what you're building.
Recap
- An API is the same Flask app, different return value — swap
render_templateforjsonify. Same routing, same request cycle; only the response shape changes (data for programs, not pages for humans). jsonifyserializes a dict and sets the JSON content type, but Flask has no built-in model serializer — you convert models to dicts yourself (anote_to_dicthelper keeps the shape in one place).- Read incoming JSON with
request.get_json()— the API counterpart torequest.form— then save throughdb.sessionexactly as before. - Status codes are part of the contract: return
(body, 201)on create,abort(404)when missing. ⚠️ Default Flask errors are HTML — register@app.errorhandlerhandlers so errors come back as JSON. - Structure a growing API with a blueprint under an
/apiprefix; marshmallow / Flask-RESTful are the extensions that automate serialization and validation — the extension pattern again. - Flask can do APIs; FastAPI was built for them. Use Flask APIs inside an existing Flask app or for full control; reach for FastAPI when the API is the product and you want validation, serialization, and docs for free.
Quick check
Three questions on the ideas that have to stick before Phase 9:
[
{
"q": "What is the core difference between a Flask view that serves an HTML page and one that serves a JSON API response?",
"choices": [
"Only what the view returns — render_template for a page, jsonify for data; the routing and request cycle are identical",
"API views must use a completely separate Flask application object",
"API views cannot use the database or models that HTML views use",
"Flask needs a special 'API mode' enabled in config before JSON works"
],
"answer": 0,
"explain": "An API is the same Flask app wearing a different hat. Same routing, same view functions, same request cycle — the only change is returning jsonify(...) instead of render_template(...). The two can coexist in one app."
},
{
"q": "You call abort(404) in an API endpoint, but your JSON client reports a parse error instead of a clean error message. Why?",
"choices": [
"Flask's default error responses are HTML pages — you must register an @app.errorhandler(404) that returns jsonify(...) so errors come back as JSON",
"abort(404) is not a real Flask function and silently does nothing",
"The client must send a special header to receive JSON errors",
"404 errors can never return a body, so there is nothing for the client to parse"
],
"answer": 0,
"explain": "Out of the box Flask returns a small HTML page for errors. A JSON client trying to parse HTML fails. Registering @app.errorhandler(404) (and 400, 500) to return jsonify({\"error\": ...}), code makes the whole API speak JSON consistently."
},
{
"q": "When is FastAPI the more natural choice over Flask for an API, according to this phase?",
"choices": [
"When the API is the product — and you want validation, serialization, and interactive docs generated automatically from type hints",
"Always — Flask cannot build production APIs at all",
"Only when you need to serve HTML pages alongside the API",
"Only for tiny one-or-two-endpoint additions to an existing app"
],
"answer": 0,
"explain": "Flask can build real APIs, but FastAPI was built for them: type hints drive automatic validation, serialization, and /docs. Reach for Flask APIs inside an existing Flask app or for full control; reach for FastAPI when the API is the whole point."
}
]
← Phase 7: Sessions, Auth & Extensions · Guide overview · Phase 9: Testing & Production →
Check your understanding
1. What is the core difference between a Flask view that serves an HTML page and one that serves a JSON API response?
2. You call abort(404) in an API endpoint, but your JSON client reports a parse error instead of a clean error message. Why?
3. When is FastAPI the more natural choice over Flask for an API, according to this phase?