Skip to content

Full CRUD Example — Patient Records

Time to build a real, working API. This combines everything from chapters 1–6 into a complete CRUD service for managing patient records — straight out of the CampusX tutorial.

CRUD = Create, Read, Update, Delete — the four standard operations on any resource.

What we're building

Method Endpoint Action
GET / API home
GET /about About info
GET /view List all patients
GET /patient/{patient_id} Get one patient by ID
GET /sort List sorted by a field
POST /create Add a new patient
PUT /edit/{patient_id} Update a patient
DELETE /delete/{patient_id} Delete a patient

Storage: a JSON file (patients.json) — simple, no database needed yet.

Step 1 — Set up the data file

Create patients.json next to your main.py:

{
  "P001": {
    "name": "Alice",
    "city": "Mumbai",
    "age": 28,
    "gender": "female",
    "height": 1.65,
    "weight": 60
  },
  "P002": {
    "name": "Bob",
    "city": "Delhi",
    "age": 35,
    "gender": "male",
    "height": 1.78,
    "weight": 82
  }
}

Step 2 — Pydantic schemas

# main.py
from pydantic import BaseModel, Field, computed_field
from typing import Annotated, Literal

class Patient(BaseModel):
    id: Annotated[str, Field(..., description="Patient ID", examples=["P003"])]
    name: Annotated[str, Field(..., max_length=50)]
    city: Annotated[str, Field(...)]
    age: Annotated[int, Field(..., gt=0, lt=120)]
    gender: Annotated[Literal["male", "female", "other"], Field(...)]
    height: Annotated[float, Field(..., gt=0, description="height in meters")]
    weight: Annotated[float, Field(..., gt=0, description="weight in kg")]

    @computed_field
    @property
    def bmi(self) -> float:
        return round(self.weight / (self.height ** 2), 2)

    @computed_field
    @property
    def verdict(self) -> str:
        b = self.bmi
        if b < 18.5: return "Underweight"
        if b < 25:   return "Normal"
        if b < 30:   return "Overweight"
        return "Obese"

class PatientUpdate(BaseModel):
    """All fields optional — for partial updates."""
    name: str | None = None
    city: str | None = None
    age: int | None = Field(None, gt=0, lt=120)
    gender: Literal["male", "female", "other"] | None = None
    height: float | None = Field(None, gt=0)
    weight: float | None = Field(None, gt=0)

computed_field automatically calculates bmi and verdict whenever a patient is returned — no manual logic needed in the endpoint.

Step 3 — File helpers

import json
from pathlib import Path

DB_PATH = Path("patients.json")

def load_data() -> dict:
    if not DB_PATH.exists():
        return {}
    with open(DB_PATH) as f:
        return json.load(f)

def save_data(data: dict) -> None:
    with open(DB_PATH, "w") as f:
        json.dump(data, f, indent=2)

Step 4 — Read endpoints

from fastapi import FastAPI, Path, HTTPException, Query

app = FastAPI(title="Patient Management API")

@app.get("/")
def hello():
    return {"message": "Patient Management API"}

@app.get("/about")
def about():
    return {"description": "A simple CRUD API for patient records"}

@app.get("/view")
def view_all():
    return load_data()

@app.get("/patient/{patient_id}")
def view_patient(
    patient_id: str = Path(..., description="ID like P001", examples=["P001"])
):
    data = load_data()
    if patient_id not in data:
        raise HTTPException(404, f"patient {patient_id} not found")
    return data[patient_id]

@app.get("/sort")
def sort_patients(
    sort_by: str = Query(..., description="field to sort by: height/weight/bmi"),
    order: str = Query("asc", description="asc or desc"),
):
    valid = ["height", "weight", "bmi"]
    if sort_by not in valid:
        raise HTTPException(400, f"sort_by must be one of {valid}")
    if order not in ("asc", "desc"):
        raise HTTPException(400, "order must be 'asc' or 'desc'")

    data = load_data()

    # Compute BMI on the fly so we can sort on it
    def key(item):
        v = item[1]
        if sort_by == "bmi":
            return round(v["weight"] / (v["height"] ** 2), 2)
        return v[sort_by]

    sorted_items = sorted(data.items(), key=key, reverse=(order == "desc"))
    return dict(sorted_items)

Step 5 — Create endpoint

@app.post("/create", status_code=201)
def create_patient(patient: Patient):
    data = load_data()

    if patient.id in data:
        raise HTTPException(400, f"patient {patient.id} already exists")

    # Store without the id (it's the dict key)
    data[patient.id] = patient.model_dump(exclude={"id"})
    save_data(data)
    return {"created": patient.id, "data": data[patient.id]}

Step 6 — Update endpoint (partial / PATCH-style)

@app.put("/edit/{patient_id}")
def edit_patient(patient_id: str, update: PatientUpdate):
    data = load_data()
    if patient_id not in data:
        raise HTTPException(404, "patient not found")

    # Only override fields the client actually sent
    existing = data[patient_id]
    incoming = update.model_dump(exclude_unset=True)
    existing.update(incoming)

    # Re-validate the merged record against the full Patient schema
    Patient(id=patient_id, **existing)

    data[patient_id] = existing
    save_data(data)
    return {"updated": patient_id, "data": existing}

exclude_unset=True is the magic — only fields the client included in the request are kept; everything else stays untouched.

Step 7 — Delete endpoint

@app.delete("/delete/{patient_id}", status_code=204)
def delete_patient(patient_id: str):
    data = load_data()
    if patient_id not in data:
        raise HTTPException(404, "patient not found")

    del data[patient_id]
    save_data(data)
    return None

204 No Content is the standard for successful delete.

Step 8 — Put it together & run

Your full main.py should look like the parts above stitched together (imports → schemas → helpers → endpoints).

uvicorn main:app --reload

Open http://127.0.0.1:8000/docs — you'll see all 8 endpoints.

Try it out

Create a patient:

curl -X POST http://127.0.0.1:8000/create \
  -H "Content-Type: application/json" \
  -d '{
    "id":"P003",
    "name":"Carol",
    "city":"Bangalore",
    "age":29,
    "gender":"female",
    "height":1.62,
    "weight":58
  }'

Get it back:

curl http://127.0.0.1:8000/patient/P003

Response (with computed BMI and verdict):

{
  "name": "Carol",
  "city": "Bangalore",
  "age": 29,
  "gender": "female",
  "height": 1.62,
  "weight": 58
}

(BMI/verdict are computed when returning a Patient schema, not when reading the JSON file directly. For consistency, you could make view_patient return a Patient via response_model — try it as an exercise.)

Update partially:

curl -X PUT http://127.0.0.1:8000/edit/P003 \
  -H "Content-Type: application/json" \
  -d '{"city": "Pune", "weight": 60}'

Sort by BMI descending:

curl "http://127.0.0.1:8000/sort?sort_by=bmi&order=desc"

Delete:

curl -X DELETE http://127.0.0.1:8000/delete/P003

How the execution flows for a POST

  1. Client sends POST /create with JSON body.
  2. uvicorn receives request.
  3. FastAPI matches the route, parses JSON body → validates against Patient model.
  4. Required fields check, type check, age > 0 and < 120 check, Literal gender check — Pydantic runs all of these.
  5. If invalid → 422 returned, your function never runs.
  6. Function runs: load JSON file → check duplicate → write back.
  7. Return value serialized to JSON.
  8. Response sent.

Common pitfalls

  • Race conditions on file writes — two simultaneous POSTs could overwrite each other. Real apps use a database (next chapter).
  • Storing the id both as a key and inside the value — pick one. The example uses the key.
  • PUT without exclude_unset=True — would overwrite all fields with the model's defaults (None), erasing the existing data.
  • No backups of patients.json — easy to corrupt during development. Commit it to git or back it up.

What's next

Practice

What does this print?

Expected: True

# CRUD = Create, Read, Update, Delete = POST, GET, PUT/PATCH, DELETE
methods = ["POST", "GET", "PUT", "DELETE"]
print(len(methods) == 4)

Use PATCH for partial updates (PUT replaces entire resource)

Expected: True

# For a partial update (changing just one field), use PATCH not PUT
method_for_partial_update = "PUT"        # bug: PUT replaces the whole resource
correct = method_for_partial_update == "PATCH"
print(not correct)

Quiz — Quick check

What you remember

Q1. What's the difference between PUT and PATCH?

  • PUT replaces the entire resource; PATCH updates only the provided fields
  • No difference
  • PUT is faster
  • PATCH is deprecated

Why: PUT semantics: "here's the full new state of the resource". PATCH semantics: "apply these specific changes". Use PATCH when clients only send the fields they're changing.

Q2. What status code should DELETE return on success?

  • 200 OK
  • 204 No Content (when not returning a body)
  • 201 Created
  • 410 Gone

Why: 204 explicitly signals "success, no body". If you do want to return the deleted resource for confirmation, 200 with body is also fine. Pick a convention and stick with it.

Q3. When you POST /patients, what URL should the new resource live at?

  • /patients/{new_id} — return this in the Location header
  • The same /patients
  • The home page
  • Anywhere

Why: RESTful convention. POST creates at the collection URL; the new resource gets its own URL with an ID. Include Location: /patients/{new_id} in the response so clients know where to find it.

Common doubts

How do I store data persistently between requests?

The example uses an in-memory dict — fine for learning, lost on restart. Production: connect to a database (Postgres + SQLAlchemy, MongoDB, Redis). For prototypes, SQLite is great — one file, zero setup.

Should I use UUID or auto-increment integer IDs?

UUID for public-facing IDs (no enumeration attacks, easier to merge across systems). Auto-increment integer for internal IDs (smaller, faster indexes). Some systems use both: integer PK + UUID column for external use.

Where should business logic live — in the handler or a service layer?

For small apps, handler is fine. For larger apps, extract a service layer: handler does HTTP concerns (parse, validate, format response), service does business logic. Easier to test (services without HTTP) and reuse (different transports).

Pydantic Deep Dive