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

*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

```plaintext
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 |
| --- | --- | --- |
| **C**reate | `POST /students` | 201 Created |
| **R**ead | `GET /students`, `GET /students/{id}` | 200 OK |
| **U**pdate | `PUT /students/{id}` | 200 OK |
| **D**elete | `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."

![](https://cdn.hashnode.com/uploads/covers/68822b6ad516e7a593530cf9/33e4dfb7-2a3c-4f83-9804-a73d75e487ad.png align="center")

---

## 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`

```python
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`

```python
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](mailto: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.

```plaintext
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`

```python
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:

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

Execute. Response, status **201**:

```json
{
  "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**:

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

Send this:

```json
{
  "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.

![](https://cdn.hashnode.com/uploads/covers/68822b6ad516e7a593530cf9/b72f2197-97c2-473d-a3d3-b729f9d21c00.png align="center")

---

## 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:

```python
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`

```python
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**

```python
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:

```json
{"marks": 91}
```

Response, status **200**:

```json
{
  "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**.

![](https://cdn.hashnode.com/uploads/covers/68822b6ad516e7a593530cf9/3427edbb-752f-4246-99df-75afce978c33.png align="center")

---

## 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`

```python
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`.

![](https://cdn.hashnode.com/uploads/covers/68822b6ad516e7a593530cf9/58a7a09f-edab-4128-b95d-bce56c0999a1.png align="center")

---

## 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

```plaintext
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.
