Skip to main content

Python 10 - CRUD: Create, Update, and Delete Students

Updated 22 min readMarkdown
Part of seriespython

Let clients send data in, change it, and remove it safely, with the right status codes, saved back to the file.

Where StudyDesk is now

src/studydesk/
├── __init__.py          ← GET /, GET /students, GET /students/{id}
├── models.py            ← Student (validated, computed grade)
├── utils.py             ← load_students()
└── data/students.json

By the end of this article, StudyDesk supports all four CRUD operations:

Operation Method & path Status on success
Create POST /students 201 Created
Read GET /students, GET /students/{id} 200 OK
Update PUT /students/{id} 200 OK
Delete DELETE /students/{id} 204 No Content

Part 1: The Admission Office Calls

The institute's admission office has a new student to register. Right now, the only way to add her is to open students.json in an editor and type carefully. Last time someone did that, they broke the file with a missing comma.

Neha says, "Reading was half the job. A real API lets clients create, update, and delete too. And every change must go through the Student model, so nobody can ever break the file again."


Part 2: Preparing the Models

Before writing routes, Aman notices a problem with the Student model: it requires id. But when a client creates a student, the client should not choose the ID; the server assigns it. And when a client updates a student, they may want to change only marks, not resend everything.

So we need three shapes of "student":

Model Used for id Fields
StudentCreate POST body no all required
StudentUpdate PUT body no all optional
Student stored record and responses yes all required + computed grade

To avoid repeating the field rules three times, we put the shared fields in a base class and inherit from it (inheritance from the Python series).

src/studydesk/models.py

from pydantic import BaseModel, EmailStr, Field, computed_field, field_validator


def clean_text(value: str) -> str:
    """Strip spaces, reject digits, convert to Title Case."""
    value = value.strip()
    if any(char.isdigit() for char in value):
        raise ValueError("must not contain digits")
    return value.title()


class StudentBase(BaseModel):
    """Fields shared by every student shape."""

    name: str = Field(min_length=2, max_length=50, description="Full name", examples=["Priya Sharma"])
    email: EmailStr = Field(description="Email address", examples=["priya@example.com"])
    age: int = Field(ge=5, le=100, description="Age in years", examples=[17])
    city: str = Field(min_length=2, max_length=50, description="City", examples=["Ahmedabad"])
    marks: int = Field(ge=0, le=100, description="Marks out of 100", examples=[88])

    @field_validator("name", "city")
    @classmethod
    def validate_text(cls, value: str) -> str:
        return clean_text(value)


class StudentCreate(StudentBase):
    """Body for POST /students. No id: the server assigns it."""


class StudentUpdate(BaseModel):
    """Body for PUT /students/{id}. Every field is optional."""

    name: str | None = Field(default=None, min_length=2, max_length=50, examples=["Priya Sharma"])
    email: EmailStr | None = Field(default=None, examples=["priya@example.com"])
    age: int | None = Field(default=None, ge=5, le=100, examples=[17])
    city: str | None = Field(default=None, min_length=2, max_length=50, examples=["Ahmedabad"])
    marks: int | None = Field(default=None, ge=0, le=100, examples=[88])

    @field_validator("name", "city")
    @classmethod
    def validate_text(cls, value: str | None) -> str | None:
        if value is None:
            return value
        return clean_text(value)


class Student(StudentBase):
    """A complete, stored student record."""

    id: int = Field(gt=0, description="Unique student ID", examples=[1])

    @computed_field
    @property
    def grade(self) -> str:
        """Letter grade calculated from marks."""
        if self.marks >= 85:
            return "A"
        if self.marks >= 70:
            return "B"
        if self.marks >= 50:
            return "C"
        return "D"

Reading it

clean_text() as a plain function The cleaning logic is used by two models, so it lives in one function at the top. Each validator just calls it.

class StudentBase(BaseModel) The five fields every student has, with all their rules and the validator.

class StudentCreate(StudentBase) Inherits everything from StudentBase and adds nothing. The body of the class is only a docstring. This gives it a separate name so it shows clearly in /docs as the POST body.

class StudentUpdate(BaseModel) Does not inherit from StudentBase, because every field must be optional here. Each field is type | None with default=None. The validator skips None so that fields the client did not send are not touched.

class Student(StudentBase) Inherits the five fields, adds id and the computed grade. This is the same model as Article 4, just restructured.

Nothing in utils.py or __init__.py breaks: Student still has the same fields and still computes grade.


Part 3: Saving to the File

Reading was load_students(). We need its partner, save_students().

src/studydesk/utils.py

import json
from pathlib import Path

from .models import Student

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


def load_students() -> list[dict]:
    """Read all students from the JSON file, validated through the Student model."""
    with open(DATA_FILE, "r", encoding="utf-8") as f:
        raw_records = json.load(f)
    return [Student(**record).model_dump() for record in raw_records]


def save_students(students: list[dict]) -> None:
    """Validate every record and write the list back to the JSON file."""
    clean_records = [Student(**record).model_dump(exclude={"grade"}) for record in students]
    with open(DATA_FILE, "w", encoding="utf-8") as f:
        json.dump(clean_records, f, indent=2)


def find_student(students: list[dict], student_id: int) -> dict | None:
    """Return the student dict with the given id, or None."""
    for student in students:
        if student["id"] == student_id:
            return student
    return None


def email_exists(students: list[dict], email: str, exclude_id: int | None = None) -> bool:
    """True if another student already uses this email (case-insensitive)."""
    for student in students:
        if student["id"] == exclude_id:
            continue
        if student["email"].lower() == email.lower():
            return True
    return False

Reading the new functions

save_students() Every record is passed through Student(**record) once more before writing. If any code path somehow produced a bad dict, it fails here instead of corrupting the file. exclude={"grade"} drops the computed field, because we decided in Article 4 never to store it. json.dump(..., indent=2) writes readable JSON.

find_student() The loop from get_student in Article 2, pulled out into a helper. Three routes will need it now.

email_exists() Emails should be unique. This checks whether someone else already has the email. exclude_id lets an update skip the student being edited (otherwise updating Priya's name would complain that "priya@example.com is already used" — by Priya).


Part 4: Building CRUD — Request Body and POST Route

What is a request body?

A GET request carries its information in the URL (path and query parameters). A POST request carries data in the body: a block of JSON sent along with the request.

POST /students
Content-Type: application/json

{
  "name": "Isha Kapoor",
  "email": "isha@example.com",
  "age": 17,
  "city": "Ahmedabad",
  "marks": 79
}

How FastAPI reads it

You already know the rules:

  • parameter name matches a {placeholder} → path parameter
  • otherwise, a simple type (int, str) → query parameter

There is a third rule: a parameter typed as a Pydantic model → request body. FastAPI reads the JSON body, validates it through the model, and hands you a clean object. Invalid body → automatic 422.

The route

src/studydesk/__init__.py

from typing import Annotated, Literal

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

from .models import Student, StudentCreate
from .utils import email_exists, find_student, load_students, save_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(
    city: Annotated[
        str | None,
        Query(description="Filter by city (case-insensitive).", examples=["Ahmedabad"]),
    ] = None,
    grade: Annotated[
        str | None,
        Query(
            description="Filter by grade: A, B, C, or D.",
            pattern="^[ABCD]$",
            examples=["A"],
        ),
    ] = None,
    min_marks: Annotated[
        int | None,
        Query(description="Only students with marks >= this value.", ge=0, le=100),
    ] = None,
    sort_by: Annotated[
        Literal["id", "name", "age", "marks"],
        Query(description="Field to sort by."),
    ] = "id",
    order: Annotated[
        Literal["asc", "desc"],
        Query(description="Sort direction: asc or desc."),
    ] = "asc",
    offset: Annotated[
        int,
        Query(description="Number of students to skip.", ge=0),
    ] = 0,
    limit: Annotated[
        int,
        Query(description="Maximum number of students to return (1-100).", ge=1, le=100),
    ] = 10,
):
    students = load_students()

    # --- filtering ---
    if city:
        students = [s for s in students if s["city"].lower() == city.lower()]

    if grade:
        students = [s for s in students if s["grade"] == grade.upper()]

    if min_marks is not None:
        students = [s for s in students if s["marks"] >= min_marks]

    # --- sorting ---
    students = sorted(students, key=lambda s: s[sort_by], reverse=(order == "desc"))

    # --- pagination ---
    total = len(students)
    total_pages = (total + limit - 1) // limit if total > 0 else 0
    page = students[offset : offset + limit]

    return {
        "total": total,
        "total_pages": total_pages,
        "offset": offset,
        "limit": limit,
        "count": len(page),
        "students": page,
    }


@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()
    student = find_student(students, student_id)
    if student is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Student with id {student_id} not found",
        )
    return student


@app.post(
    "/students",
    tags=["Students"],
    summary="Create a new student",
    status_code=status.HTTP_201_CREATED,
)
def create_student(payload: StudentCreate):
    students = load_students()

    if email_exists(students, payload.email):
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail=f"A student with email {payload.email} already exists",
        )

    next_id = max((s["id"] for s in students), default=0) + 1
    new_student = Student(id=next_id, **payload.model_dump())

    students.append(new_student.model_dump())
    save_students(students)

    return new_student


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.post(..., status_code=status.HTTP_201_CREATED) The default success code is 200. For "I created something new", the correct code is 201. Setting it in the decorator means FastAPI uses it whenever the function returns normally.

def create_student(payload: StudentCreate): payload is typed as a Pydantic model, so FastAPI reads it from the JSON body. By the time your code runs, payload is a validated StudentCreate object: name cleaned, email checked, marks in range. You never see bad data.

Duplicate check → 409 If the email is taken, we raise 409 Conflict. That is the code for "your request is valid, but it clashes with something that already exists."

next_id = max((s["id"] for s in students), default=0) + 1 Find the largest existing ID and add one. max() with default=0 handles the empty-file case (then the first ID is 1). The part inside max() is a generator expression: like a list comprehension, but without building a list. It just feeds IDs to max() one by one.

Student(id=next_id, **payload.model_dump()) Build the full record: the server-chosen ID plus the five fields from the client. payload.model_dump() turns the StudentCreate into a dict, and ** unpacks it into keyword arguments (both from Article 4). Passing through Student also computes the grade.

students.append(...) then save_students(students) Add the new dict to the list and write the whole list back.

return new_student Return the Student object directly. FastAPI converts Pydantic models to JSON automatically, computed grade included. Clients get back the record with its new ID.

get_student now uses find_student() Same behaviour as before, shorter code.

Test it in /docs

Open POST /students, click Try it out. The body box is already filled with an example built from the examples=[...] in the model. Replace it with:

{
  "name": "isha kapoor",
  "email": "isha@example.com",
  "age": 17,
  "city": "ahmedabad",
  "marks": 79
}

Execute. Response, status 201:

{
  "name": "Isha Kapoor",
  "email": "isha@example.com",
  "age": 17,
  "city": "Ahmedabad",
  "marks": 79,
  "id": 11,
  "grade": "B"
}

The name and city were title-cased by the validator. The ID was assigned. The grade was computed. Open students.json: Isha is there, without a grade key.

Send it again, same email. Status 409:

{"detail": "A student with email isha@example.com already exists"}

Send this:

{
  "name": "X",
  "email": "nope",
  "age": 3,
  "city": "Surat",
  "marks": 120
}

Status 422, with four errors listed, one per field. You wrote none of that code.


Part 5: Building CRUD — PUT Route with Partial Updates

Full update vs partial update

Strict REST says PUT replaces the whole record and PATCH changes part of it. In practice, many APIs use PUT for both: send only the fields you want to change, and the rest stay as they are. That is what we build.

The challenge: how do we know which fields the client sent?

exclude_unset=True

StudentUpdate has every field defaulting to None. If the client sends {"marks": 91}, then payload.name is None, payload.email is None, and so on. We cannot just copy all of them, or we would erase the name.

Pydantic remembers which fields were actually set by the caller. payload.model_dump(exclude_unset=True) returns only those:

payload = StudentUpdate(marks=91)
payload.model_dump()                     # {'name': None, 'email': None, 'age': None, 'city': None, 'marks': 91}
payload.model_dump(exclude_unset=True)   # {'marks': 91}

That second dict is exactly what we want to merge into the existing record.

The route

Add the import and the route. Full file:

src/studydesk/__init__.py

from typing import Annotated, Literal

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

from .models import Student, StudentCreate, StudentUpdate
from .utils import email_exists, find_student, load_students, save_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(
    city: Annotated[
        str | None,
        Query(description="Filter by city (case-insensitive).", examples=["Ahmedabad"]),
    ] = None,
    grade: Annotated[
        str | None,
        Query(
            description="Filter by grade: A, B, C, or D.",
            pattern="^[ABCD]$",
            examples=["A"],
        ),
    ] = None,
    min_marks: Annotated[
        int | None,
        Query(description="Only students with marks >= this value.", ge=0, le=100),
    ] = None,
    sort_by: Annotated[
        Literal["id", "name", "age", "marks"],
        Query(description="Field to sort by."),
    ] = "id",
    order: Annotated[
        Literal["asc", "desc"],
        Query(description="Sort direction: asc or desc."),
    ] = "asc",
    offset: Annotated[
        int,
        Query(description="Number of students to skip.", ge=0),
    ] = 0,
    limit: Annotated[
        int,
        Query(description="Maximum number of students to return (1-100).", ge=1, le=100),
    ] = 10,
):
    students = load_students()

    # --- filtering ---
    if city:
        students = [s for s in students if s["city"].lower() == city.lower()]

    if grade:
        students = [s for s in students if s["grade"] == grade.upper()]

    if min_marks is not None:
        students = [s for s in students if s["marks"] >= min_marks]

    # --- sorting ---
    students = sorted(students, key=lambda s: s[sort_by], reverse=(order == "desc"))

    # --- pagination ---
    total = len(students)
    total_pages = (total + limit - 1) // limit if total > 0 else 0
    page = students[offset : offset + limit]

    return {
        "total": total,
        "total_pages": total_pages,
        "offset": offset,
        "limit": limit,
        "count": len(page),
        "students": page,
    }


@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()
    student = find_student(students, student_id)
    if student is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Student with id {student_id} not found",
        )
    return student


@app.post(
    "/students",
    tags=["Students"],
    summary="Create a new student",
    status_code=status.HTTP_201_CREATED,
)
def create_student(payload: StudentCreate):
    students = load_students()

    if email_exists(students, payload.email):
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail=f"A student with email {payload.email} already exists",
        )

    next_id = max((s["id"] for s in students), default=0) + 1
    new_student = Student(id=next_id, **payload.model_dump())

    students.append(new_student.model_dump())
    save_students(students)

    return new_student


@app.put("/students/{student_id}", tags=["Students"], summary="Update a student (partial)")
def update_student(
    student_id: Annotated[int, Path(title="Student ID", gt=0, examples=[1])],
    payload: StudentUpdate,
):
    students = load_students()
    student = find_student(students, student_id)
    if student is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Student with id {student_id} not found",
        )

    changes = payload.model_dump(exclude_unset=True)
    if not changes:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="No fields provided to update",
        )

    if "email" in changes and email_exists(students, changes["email"], exclude_id=student_id):
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail=f"A student with email {changes['email']} already exists",
        )

    merged = student.copy()
    merged.update(changes)
    merged.pop("grade", None)
    updated_student = Student(**merged)

    for index, s in enumerate(students):
        if s["id"] == student_id:
            students[index] = updated_student.model_dump()
            break

    save_students(students)
    return updated_student


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

Reading the new route

Two parameters: path and body student_id matches {student_id}, so it is a path parameter. payload is a Pydantic model, so it is the body. FastAPI handles both at once.

404 first Nothing to update if the student does not exist.

changes = payload.model_dump(exclude_unset=True) Only the fields the client actually sent.

Empty body → 400 PUT /students/3 with {} is a valid request that asks for nothing. We reject it with 400 Bad Request so the client knows it made a mistake.

Email uniqueness, excluding self exclude_id=student_id lets a student keep their own email while changing other fields.

Merging

merged = student.copy()      # a copy of the existing dict (don't modify the original directly)
merged.update(changes)       # overwrite only the sent fields
merged.pop("grade", None)    # drop the stored computed field; Student will recompute it
updated_student = Student(**merged)

dict.update() copies keys from changes into merged, replacing the old values. dict.pop("grade", None) removes grade if present and does nothing if it is not. Building Student(**merged) re-validates the whole record and recomputes grade from the new marks.

Replacing in the list enumerate() gives us the position of each student. When we find the matching ID, we overwrite that slot with the updated dict and break out of the loop.

Return 200 with the updated record Default status code. The client sees the final state, including the recomputed grade.

Test it

PUT /students/4 with body:

{"marks": 91}

Response, status 200:

{
  "name": "Arjun Mehta",
  "email": "arjun@example.com",
  "age": 17,
  "city": "Vadodara",
  "marks": 91,
  "id": 4,
  "grade": "A"
}

Only marks changed. Everything else stayed. Grade moved from C to A automatically.

PUT /students/4 with {} → 400, "No fields provided to update".

PUT /students/4 with {"email": "priya@example.com"} → 409, Priya already has that email.

PUT /students/4 with {"email": "arjun@example.com", "city": "surat"} → 200. Same email as before is fine (excluded self), city becomes Surat.

PUT /students/999 with {"marks": 50} → 404.


Part 6: Building CRUD — DELETE Route

The last operation. Delete is simple, but the status code is new.

204 No Content

When a DELETE succeeds, there is nothing meaningful to return. The thing is gone. The HTTP code for "success, and the body is empty" is 204 No Content. A 204 response must have no body at all; FastAPI enforces this.

The route

Full file:

src/studydesk/__init__.py

from typing import Annotated, Literal

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

from .models import Student, StudentCreate, StudentUpdate
from .utils import email_exists, find_student, load_students, save_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(
    city: Annotated[
        str | None,
        Query(description="Filter by city (case-insensitive).", examples=["Ahmedabad"]),
    ] = None,
    grade: Annotated[
        str | None,
        Query(
            description="Filter by grade: A, B, C, or D.",
            pattern="^[ABCD]$",
            examples=["A"],
        ),
    ] = None,
    min_marks: Annotated[
        int | None,
        Query(description="Only students with marks >= this value.", ge=0, le=100),
    ] = None,
    sort_by: Annotated[
        Literal["id", "name", "age", "marks"],
        Query(description="Field to sort by."),
    ] = "id",
    order: Annotated[
        Literal["asc", "desc"],
        Query(description="Sort direction: asc or desc."),
    ] = "asc",
    offset: Annotated[
        int,
        Query(description="Number of students to skip.", ge=0),
    ] = 0,
    limit: Annotated[
        int,
        Query(description="Maximum number of students to return (1-100).", ge=1, le=100),
    ] = 10,
):
    students = load_students()

    # --- filtering ---
    if city:
        students = [s for s in students if s["city"].lower() == city.lower()]

    if grade:
        students = [s for s in students if s["grade"] == grade.upper()]

    if min_marks is not None:
        students = [s for s in students if s["marks"] >= min_marks]

    # --- sorting ---
    students = sorted(students, key=lambda s: s[sort_by], reverse=(order == "desc"))

    # --- pagination ---
    total = len(students)
    total_pages = (total + limit - 1) // limit if total > 0 else 0
    page = students[offset : offset + limit]

    return {
        "total": total,
        "total_pages": total_pages,
        "offset": offset,
        "limit": limit,
        "count": len(page),
        "students": page,
    }


@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()
    student = find_student(students, student_id)
    if student is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Student with id {student_id} not found",
        )
    return student


@app.post(
    "/students",
    tags=["Students"],
    summary="Create a new student",
    status_code=status.HTTP_201_CREATED,
)
def create_student(payload: StudentCreate):
    students = load_students()

    if email_exists(students, payload.email):
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail=f"A student with email {payload.email} already exists",
        )

    next_id = max((s["id"] for s in students), default=0) + 1
    new_student = Student(id=next_id, **payload.model_dump())

    students.append(new_student.model_dump())
    save_students(students)

    return new_student


@app.put("/students/{student_id}", tags=["Students"], summary="Update a student (partial)")
def update_student(
    student_id: Annotated[int, Path(title="Student ID", gt=0, examples=[1])],
    payload: StudentUpdate,
):
    students = load_students()
    student = find_student(students, student_id)
    if student is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Student with id {student_id} not found",
        )

    changes = payload.model_dump(exclude_unset=True)
    if not changes:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="No fields provided to update",
        )

    if "email" in changes and email_exists(students, changes["email"], exclude_id=student_id):
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail=f"A student with email {changes['email']} already exists",
        )

    merged = student.copy()
    merged.update(changes)
    merged.pop("grade", None)
    updated_student = Student(**merged)

    for index, s in enumerate(students):
        if s["id"] == student_id:
            students[index] = updated_student.model_dump()
            break

    save_students(students)
    return updated_student


@app.delete(
    "/students/{student_id}",
    tags=["Students"],
    summary="Delete a student",
    status_code=status.HTTP_204_NO_CONTENT,
)
def delete_student(
    student_id: Annotated[int, Path(title="Student ID", gt=0, examples=[1])],
):
    students = load_students()
    student = find_student(students, student_id)
    if student is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Student with id {student_id} not found",
        )

    students = [s for s in students if s["id"] != student_id]
    save_students(students)


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

Reading the new route

status_code=status.HTTP_204_NO_CONTENT Success means 204.

404 if missing Deleting something that does not exist is an error, not a silent success. (Some APIs choose to return 204 anyway, treating delete as "make sure it's gone". Both are accepted; we pick 404 because it gives the client more information.)

students = [s for s in students if s["id"] != student_id] Keep every student except the one with this ID. A filter, the same shape as the ones in get_students.

No return The function returns None, and with a 204 status FastAPI sends an empty body. Do not return a message like {"deleted": true}; a 204 with a body is invalid HTTP.

Test it

DELETE /students/11 (Isha, created earlier) → status 204, empty response. GET /students/11 → 404. DELETE /students/11 again → 404. GET /students → total: 10.


Part 7: The Full Picture

Open /docs. Under Students you now see:

Method Path Summary Success Errors
GET /students Get all students 200 422
POST /students Create a new student 201 409, 422
GET /students/{student_id} Get one student by ID 200 404, 422
PUT /students/{student_id} Update a student (partial) 200 400, 404, 409, 422
DELETE /students/{student_id} Delete a student 204 404, 422

Every route reads through load_students(), writes through save_students(), and validates through Student. The JSON file can no longer be broken through the API.

A note on the JSON file

Reading and rewriting the whole file on every request is fine for learning and for ten students. It is not fine for a real system: two requests at the same moment can overwrite each other, and 30,000 students would make every request slow. That is exactly why Article 7 moves StudyDesk to PostgreSQL. The routes you wrote today will keep almost the same shape; only the storage underneath changes.


Where StudyDesk is after this article

studydesk/
└── src/
    └── studydesk/
        ├── __init__.py          ← full CRUD: GET, POST, PUT, DELETE
        ├── models.py            ← StudentBase, StudentCreate, StudentUpdate, Student
        ├── utils.py             ← load, save, find_student, email_exists
        └── data/students.json

Concept Quiz

Answers are below. No coding needed.

Q1. Why do we need three models (StudentCreate, StudentUpdate, Student) instead of one?

Q2. How does FastAPI decide that payload: StudentCreate should be read from the request body?

Q3. Which status code does a successful POST return, and why not 200?

Q4. When do we return 409, and how is it different from 422?

Q5. What does model_dump(exclude_unset=True) return for StudentUpdate(marks=91)?

Q6. Why do we pop("grade") from the merged dict before building Student(**merged)?

Q7. Why does email_exists() take an exclude_id parameter?

Q8. What does a 204 response contain, and what happens if you try to return a body with it?

Q9. Why does save_students() validate every record again before writing?

Q10. What are the two main problems with using a JSON file as storage for a real API?


Answers

  1. They have different shapes: create has no id (server assigns it); update has every field optional; the stored Student has id and the computed grade. One model cannot be required and optional at the same time.
  2. Any parameter typed as a Pydantic model is treated as the JSON body. Path placeholders become path parameters; simple types become query parameters.
  3. 201 Created. 200 means "OK"; 201 specifically tells the client that a new resource now exists.
  4. 409 Conflict when the request is valid but clashes with existing data (duplicate email). 422 when the request data itself is invalid (bad email format, marks out of range).
  5. {"marks": 91} — only the field the caller actually set.
  6. grade is a computed field. If we pass it in, it is ignored at best; removing it makes clear that Student recomputes it from the new marks.
  7. So that during an update, the student's own current email is not counted as "already taken" by someone else.
  8. Nothing — an empty body. FastAPI raises an error if a route with status 204 returns a body, because a 204 with content is invalid HTTP.
  9. As a safety net. If any code path produced an invalid dict, it fails before touching the file, so the file can never be corrupted.
  10. Concurrent requests can overwrite each other's changes, and reading/writing the whole file on every request becomes very slow as data grows.

Epilogue

The admission office registers Isha through /docs. Her name comes back title-cased, her ID assigned, her grade calculated. Nobody opened a text editor.

Neha says, "The API is complete for one user sitting at Swagger. But the office staff don't want Swagger. They want a screen with a form and a table."

In the next article: frontends — a Streamlit dashboard and a plain HTML page, both talking to StudyDesk, and the CORS setting that makes the browser allow it.

No comments yet. Be the first to comment.

More from this blog