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).
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:
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:
Delete:
How the execution flows for a POST¶
- Client sends
POST /createwith JSON body. - uvicorn receives request.
- FastAPI matches the route, parses JSON body → validates against
Patientmodel. - Required fields check, type check,
age > 0 and < 120check,Literalgender check — Pydantic runs all of these. - If invalid → 422 returned, your function never runs.
- Function runs: load JSON file → check duplicate → write back.
- Return value serialized to JSON.
- 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
idboth 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
Use PATCH for partial updates (PUT replaces entire resource)
Expected: True
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 theLocationheader - 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).