Skip to main content

Python 7 - Serving Real Data: JSON, Status Codes, and Path Parameters

Updated 14 min readMarkdown
Part of seriespython

Load students from a file, return one student by ID, and answer correctly when something goes wrong.

Where StudyDesk is now

studydesk/
└── src/
    └── studydesk/
        └── __init__.py     ← app with 5 preview routes that only return messages

By the end of this article, GET /students returns real students from a JSON file, GET /students/{id} returns one student, and the API answers with proper status codes when a student does not exist or the ID is invalid.


Part 1: The API That Always Says Yes

Aman shows Neha his five preview routes. She opens /docs, clicks GET /students, and gets:

{"message": "This will return all students"}

"That is a promise, not data," she says. "And try this." She types /students/999 into the browser.

The API happily returns the same message. There is no student 999. There is no student at all. But the API says everything is fine.

"A real API has two jobs," Neha explains. "Return the right data when things go well. And return the right error when they don't. Today we do both."


Part 2: Creating the Data File

Until Article 7 (PostgreSQL), StudyDesk keeps its students in a JSON file. This is enough to learn every FastAPI concept, and we will swap it for a real database later without changing the API's shape.

Create a data folder inside the package, and a file in it.

src/studydesk/data/students.json

[
  {
    "id": 1,
    "name": "Priya Sharma",
    "email": "priya@example.com",
    "age": 17,
    "city": "Ahmedabad",
    "marks": 88,
    "grade": "A"
  },
  {
    "id": 2,
    "name": "Rahul Verma",
    "email": "rahul@example.com",
    "age": 18,
    "city": "Surat",
    "marks": 72,
    "grade": "B"
  },
  {
    "id": 3,
    "name": "Sneha Patel",
    "email": "sneha@example.com",
    "age": 16,
    "city": "Ahmedabad",
    "marks": 95,
    "grade": "A"
  },
  {
    "id": 4,
    "name": "Arjun Mehta",
    "email": "arjun@example.com",
    "age": 17,
    "city": "Vadodara",
    "marks": 58,
    "grade": "C"
  },
  {
    "id": 5,
    "name": "Kavya Nair",
    "email": "kavya@example.com",
    "age": 18,
    "city": "Rajkot",
    "marks": 81,
    "grade": "B"
  },
  {
    "id": 6,
    "name": "Dev Joshi",
    "email": "dev@example.com",
    "age": 16,
    "city": "Surat",
    "marks": 45,
    "grade": "D"
  },
  {
    "id": 7,
    "name": "Ananya Desai",
    "email": "ananya@example.com",
    "age": 17,
    "city": "Ahmedabad",
    "marks": 90,
    "grade": "A"
  },
  {
    "id": 8,
    "name": "Rohan Shah",
    "email": "rohan@example.com",
    "age": 18,
    "city": "Vadodara",
    "marks": 67,
    "grade": "C"
  },
  {
    "id": 9,
    "name": "Meera Iyer",
    "email": "meera@example.com",
    "age": 17,
    "city": "Rajkot",
    "marks": 76,
    "grade": "B"
  },
  {
    "id": 10,
    "name": "Yash Trivedi",
    "email": "yash@example.com",
    "age": 16,
    "city": "Surat",
    "marks": 84,
    "grade": "B"
  }
]

A JSON list of ten student objects. Each has the same seven keys. The grade follows a simple rule we will automate in Article 4: A for 85 and above, B for 70–84, C for 50–69, D below 50.

Your folder now:

src/studydesk/
├── __init__.py
└── data/
    └── students.json

Part 3: Loading JSON with a Helper Function

Aman could write open("students.json") inside every route. Neha stops him: "The file will be read in five different routes. Write it once."

Create a new file next to __init__.py.

src/studydesk/utils.py

import json
from pathlib import Path

DATA_FILE = Path(__file__).parent / "data" / "students.json"


def load_students() -> list[dict]:
    """Read all students from the JSON file."""
    with open(DATA_FILE, "r", encoding="utf-8") as f:
        return json.load(f)

Reading it

Path(__file__).parent / "data" / "students.json" From the Python series: __file__ is this file's path (utils.py), .parent is its folder (studydesk/), and we join data/students.json. This works no matter which folder you start the server from.

-> list[dict] A type hint. The function returns a list of dictionaries. This is only a note for readers and VS Code, but in FastAPI type hints become important, so we start the habit now.

json.load(f) Converts the file's JSON text into a Python list of dictionaries.

Why a separate utils.py?

  • Routes stay short and readable.
  • When we switch to PostgreSQL in Article 7, we change utils.py, and the routes barely notice.
  • This is the "one module per responsibility" rule from the Python series.

Part 4: GET /students — Return Everyone

Now the real route. Replace __init__.py with this. The preview POST, PUT, and DELETE routes are removed; we will build them properly in Article 5.

src/studydesk/__init__.py

import uvicorn
from fastapi import FastAPI

from .utils import load_students

app = FastAPI(
    title="StudyDesk API",
    description="Student records API for a coaching institute.",
    version="0.1.0",
)


@app.get("/")
def home():
    return {"message": "Welcome to StudyDesk API"}


@app.get("/students")
def get_students():
    students = load_students()
    return {"count": len(students), "students": students}


def main() -> None:
    """Start the development server."""
    uvicorn.run("studydesk:app", host="127.0.0.1", port=8000, reload=True)

The new line: from .utils import load_students

The dot means "from the same package". utils.py sits next to __init__.py inside studydesk/, so .utils finds it. This is called a relative import, and it is the normal way to import between files inside one package.

Run and test

uv run studydesk

Open http://127.0.0.1:8000/students:

{
  "count": 10,
  "students": [
    {
      "id": 1,
      "name": "Priya Sharma",
      "email": "priya@example.com",
      "age": 17,
      "city": "Ahmedabad",
      "marks": 88,
      "grade": "A"
    },
    ...
  ]
}

Real data. Aman wraps the list in a dictionary with count so clients know how many students they got without counting. This is a common, friendly habit.


Part 5: Understanding HTTP Status Codes

Before building the "one student" route, Aman needs to understand how an API says "something went wrong."

Every HTTP response has two parts: a body (the JSON) and a status code (a three-digit number). The body is for the data. The status code is for the outcome. Clients read the status code first.

The five families

Range Meaning Who is "at fault"
1xx Information (rare, ignore for now)
2xx Success Nobody. It worked.
3xx Redirect Nobody. Look elsewhere.
4xx Client error The client sent something wrong.
5xx Server error The server broke.

The codes you will use in StudyDesk

Code Name When
200 OK A GET or PUT worked
201 Created A POST created something new
204 No Content A DELETE worked, nothing to return
400 Bad Request The request makes no sense
401 Unauthorized Not logged in (Article 10)
403 Forbidden Logged in, but not allowed (Article 10)
404 Not Found The thing does not exist
409 Conflict Already exists (duplicate email, etc.)
422 Unprocessable Entity Data was readable but invalid (FastAPI sends this automatically)
500 Internal Server Error Your code crashed

Why not just return {"error": "not found"} with 200?

Because clients and tools look at the status code, not inside the JSON:

  • A mobile app checks if response.status_code == 200 before parsing.
  • Monitoring tools count 4xx and 5xx to alert you.
  • Browsers and caches behave differently for 404 vs 200.

A 200 that contains an error is a lie. It will cause silent bugs in every client.

The one to remember

If a human can fix it by changing the request, it is 4xx. If only you can fix it by changing the server, it is 5xx.


Part 6: Path Parameters and HTTPException

A path parameter is a variable part of the URL. In /students/5, the 5 is the parameter. Different IDs, same route.

The route

src/studydesk/__init__.py

import uvicorn
from fastapi import FastAPI, HTTPException

from .utils import load_students

app = FastAPI(
    title="StudyDesk API",
    description="Student records API for a coaching institute.",
    version="0.1.0",
)


@app.get("/")
def home():
    return {"message": "Welcome to StudyDesk API"}


@app.get("/students")
def get_students():
    students = load_students()
    return {"count": len(students), "students": students}


@app.get("/students/{student_id}")
def get_student(student_id: int):
    students = load_students()
    for student in students:
        if student["id"] == student_id:
            return student
    raise HTTPException(status_code=404, detail=f"Student with id {student_id} not found")


def main() -> None:
    """Start the development server."""
    uvicorn.run("studydesk:app", host="127.0.0.1", port=8000, reload=True)

Reading the new route

@app.get("/students/{student_id}") The curly braces mark a path parameter named student_id.

def get_student(student_id: int): The function must have a parameter with the same name. FastAPI pulls the value from the URL and passes it in. The type hint int does two jobs: it converts the text "5" into the number 5, and it rejects anything that is not a number.

The loop Go through all students. If one has a matching ID, return it. FastAPI sends it with status 200 automatically.

raise HTTPException(status_code=404, detail=...) If the loop finishes without finding anyone, we raise an HTTPException. This is how a FastAPI route says "stop, and send this error." FastAPI catches it and sends a response with status 404 and the body:

{"detail": "Student with id 999 not found"}

Note that it is raise, not return. An HTTPException is an exception, like ValueError from the Python series. Raising it stops the function immediately.

Test the three cases

A valid ID — http://127.0.0.1:8000/students/3

{
  "id": 3,
  "name": "Sneha Patel",
  "email": "sneha@example.com",
  "age": 16,
  "city": "Ahmedabad",
  "marks": 95,
  "grade": "A"
}

Status: 200.

An ID that does not exist — http://127.0.0.1:8000/students/999

{"detail": "Student with id 999 not found"}

Status: 404. Our HTTPException.

Not a number at all — http://127.0.0.1:8000/students/abc

{
  "detail": [
    {
      "type": "int_parsing",
      "loc": ["path", "student_id"],
      "msg": "Input should be a valid integer, unable to parse string as an integer",
      "input": "abc"
    }
  ]
}

Status: 422. We wrote no code for this. The int type hint did it. FastAPI explains exactly where the problem is (loc: the path parameter student_id) and what was received (abc).

Three outcomes, three correct status codes. Open /docs and you will see all of this documented automatically, including the student_id input box.

Using named constants for status codes

Typing 404 works, but FastAPI provides named constants that make code easier to read:

from fastapi import status

raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="...")

We will use this style from now on. It also makes VS Code autocomplete all codes for you.


Part 7: Customizing Path Parameters (Descriptions and Validation)

The route works, but two things can be better:

  1. /students/0 and /students/-5 are accepted and then return 404. IDs are never zero or negative, so we should reject them before reading the file.
  2. In /docs, student_id has no explanation.

FastAPI's Path() fixes both.

src/studydesk/__init__.py

from typing import Annotated

import uvicorn
from fastapi import FastAPI, HTTPException, Path, status

from .utils import load_students

app = FastAPI(
    title="StudyDesk API",
    description="Student records API for a coaching institute.",
    version="0.1.0",
)


@app.get("/", tags=["General"])
def home():
    return {"message": "Welcome to StudyDesk API"}


@app.get("/students", tags=["Students"], summary="Get all students")
def get_students():
    students = load_students()
    return {"count": len(students), "students": students}


@app.get("/students/{student_id}", tags=["Students"], summary="Get one student by ID")
def get_student(
    student_id: Annotated[
        int,
        Path(
            title="Student ID",
            description="The unique ID of the student. Must be a positive integer.",
            gt=0,
            examples=[1],
        ),
    ],
):
    students = load_students()
    for student in students:
        if student["id"] == student_id:
            return student
    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail=f"Student with id {student_id} not found",
    )


def main() -> None:
    """Start the development server."""
    uvicorn.run("studydesk:app", host="127.0.0.1", port=8000, reload=True)

What changed

Annotated[int, Path(...)] Annotated is from Python's typing module. It lets you attach extra information to a type hint. Here: "this is an int, and here are the rules for it as a path parameter." This is the modern FastAPI style and the one the official docs recommend.

Path(...) options

Option What it does
title Short label in /docs
description Longer explanation in /docs
gt=0 Greater than 0. Zero and negatives are rejected with 422
examples=[1] Pre-fills the "Try it out" box

Other validation options you can use: ge (greater than or equal), lt (less than), le (less than or equal). For string path parameters: min_length, max_length, pattern (a regex).

tags=[...] Groups routes in /docs under a heading. Now "General" and "Students" appear as separate sections. As StudyDesk grows (auth, courses), tags keep the docs page tidy.

summary="..." A one-line description shown next to each route in /docs, instead of the function name.

Test

http://127.0.0.1:8000/students/0

{
  "detail": [
    {
      "type": "greater_than",
      "loc": ["path", "student_id"],
      "msg": "Input should be greater than 0",
      "input": "0",
      "ctx": {"gt": 0}
    }
  ]
}

Status 422, and the file was never opened. Validation happens before the function body runs.

Open /docs. The student_id field now shows the title, the description, and the example. Click Try it out and the box already contains 1.


Where StudyDesk is after this article

studydesk/
├── .venv/
├── src/
│   └── studydesk/
│       ├── __init__.py          ← app, GET /, GET /students, GET /students/{id}
│       ├── utils.py             ← load_students()
│       └── data/
│           └── students.json    ← 10 students
├── .gitignore
├── .python-version
├── pyproject.toml
├── README.md
└── uv.lock

Run:

uv run studydesk
Request Status Result
GET /students 200 All 10 students with a count
GET /students/3 200 Sneha Patel
GET /students/999 404 {"detail": "Student with id 999 not found"}
GET /students/abc 422 Automatic "not an integer" error
GET /students/0 422 Automatic "greater than 0" error

Concept Quiz

Answers are below. No coding needed.

Q1. Why do we put file reading in utils.py instead of inside each route?

Q2. What does the dot mean in from .utils import load_students?

Q3. In /students/{student_id}, what must be true about the function's parameter name?

Q4. What two jobs does the type hint student_id: int do?

Q5. What is the difference between returning a dictionary and raising HTTPException?

Q6. Which status code means "the thing does not exist"? Which means "the data was invalid"?

Q7. Why is returning {"error": "not found"} with status 200 a bad idea?

Q8. If a client sends /students/abc, who produces the 422 error: your code or FastAPI?

Q9. What does gt=0 in Path() do, and when does the check run?

Q10. What is Annotated used for in FastAPI?


Answers

  1. One place to change. When we switch to a database, only utils.py changes. Routes stay short and readable.
  2. "From the same package." It is a relative import: look for utils.py next to this file inside studydesk/.
  3. It must exactly match the name inside the curly braces: student_id.
  4. It converts the URL text "5" to the integer 5, and it rejects non-numbers with a 422 error.
  5. Returning sends a 200 response with that data. Raising HTTPException stops the function immediately and sends an error response with the status code you choose.
  6. 404 Not Found; 422 Unprocessable Entity.
  7. Clients and monitoring tools read the status code, not the body. A 200 that contains an error hides the problem and causes silent bugs.
  8. FastAPI, automatically, because of the int type hint. Your code never runs.
  9. Rejects values that are not greater than 0 with a 422. It runs before the function body, so the file is never opened for bad IDs.
  10. To attach extra rules and documentation (Path(...), later Query(...) and Body(...)) to a parameter's type hint.

Epilogue

Aman refreshes /docs. Two grouped sections. Descriptions on every parameter. He types 999 and gets a clean 404. He types abc and gets a clean 422. He types 3 and gets Sneha.

Neha nods. "The API tells the truth now. But a client still has to download all ten students to find the ones from Ahmedabad. With 300 students, that's wasteful."

In the next article: query parameters — filtering by city and grade, sorting by marks, and paginating with offset and limit.

No comments yet. Be the first to comment.

More from this blog