
I told the class before we typed a single line: "By the end of today, you will have a fully working API that adds, reads, updates, and deletes student records. We will test it live in the browser. No shortcuts, no copy-paste from a finished repo. We're building this together, file by file."
Five files. One database. A real REST API by the end of the session.
This is how it went - including the moment that made the whole class lean forward at once.
The Plan: Five Files, One Job Each
Before any code, I wrote five filenames on the board:
database.py models.py schemas.py crud.py main.py
I pointed at each one as we built it, so nobody lost track of where we were in the journey. That board stayed up the entire session - every time someone looked confused, I'd point back at it. "We're in file 3. Schemas. We just finished models."
Five files, five distinct jobs:
- database.py - connects Python to the database
- models.py - defines what the database table looks like
- schemas.py - defines what the API accepts and returns
- crud.py - does the actual database work
- main.py - wires everything together into endpoints
File 1: database.py - The Foundation
"This file does one thing," I told them. "It sets up the connection between our Python code and the database. Everything else depends on this, so we write it first."
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
DATABASE_URL = "sqlite:///./grades.db"
engine = create_engine(DATABASE_URL,
connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False,
autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
The analogy that did the most work in this file: SQLAlchemy is a translator. Our database speaks SQL. We speak Python. SQLAlchemy translates between the two so we never write raw SQL ourselves.
SessionLocal got its own analogy - a coffee machine. The machine itself isn't a cup of coffee, but you press a button and get a fresh cup. SessionLocal is the machine. Every request gets its own fresh session and throws it away when done.
The line that needed the most unpacking was yield db. I told them: normally a function returns and ends. yield gives something back but the function stays alive, waiting. The endpoint runs using that session. When the endpoint finishes, Python comes back here and runs the finally block - closing the session.
One student asked the question I wait for every cohort: "Why not just open and close the session inside each endpoint?"
You could. But you'd repeat the same five lines in every single function. get_db() writes it once, and every endpoint reuses it through dependency injection. Write the pattern once, never again.
File 2: models.py - The Database Table
"We're not writing SQL. We're writing Python. SQLAlchemy generates the SQL for us."
from sqlalchemy import Column, Integer, String, Float
from database import Base
class Student(Base):
__tablename__ = "students"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
course = Column(String, nullable=False)
grade = Column(Float, default=0.0)
email = Column(String, unique=True)
I drew this table on the board as we went - column name on the left, what it does on the right. The visual learners in the room followed noticeably better once they could see the table taking shape rather than just reading code.
The id column got the school registration number analogy - every student gets a unique number automatically, nobody assigns it manually. email = Column(String, unique=True) got a direct question on the spot:
"What happens if two students try to use the same email?"
The database rejects it. We enforce that rule once, here, and never have to check for duplicate emails manually anywhere else in the code.
Eleven lines. A complete table definition. No CREATE TABLE statement in sight.
File 3: schemas.py - The Part That Confuses Everyone First
I paused before writing any code for this file and said: "This is the one that confuses beginners the most. You'll ask - why do I need schemas if I already have models? Let me explain before we write a single line."
I put two columns on the board:
| SQLAlchemy Model | Pydantic Schema |
|---|---|
| Talks to the database | Talks to the API user |
| Defines table columns | Validates incoming data |
| Never shown to users directly | Shapes the response |
"The model is what the database sees. The schema is what the user sees. If we stored passwords, the model would have a password column - but the response schema would never include it."
That distinction landed. Then the code:
from pydantic import BaseModel
from typing import Optional
class StudentCreate(BaseModel):
name: str
course: str
grade: float = 0.0
email: str
class StudentUpdate(BaseModel):
name: Optional[str] = None
course: Optional[str] = None
grade: Optional[float] = None
class StudentResponse(BaseModel):
id: int
name: str
course: str
grade: float
email: str
class Config:
from_attributes = True
Three schemas, three jobs. StudentCreate has no id field - deliberately. We never ask the user for an id; the database assigns it.
StudentUpdate made everyone notice something: every field is Optional. "Why? Because if a student's name changes, why should the user have to resend the email and course too? Optional fields let people send only what changed."
StudentResponse got the most attention because of one line: from_attributes = True. I called it out directly: "Without this one line, Pydantic cannot read a SQLAlchemy object. It only understands plain dictionaries by default. This line tells it: also accept database objects." I warned them this is the number one mistake in this file - forget it, and you get a cryptic "value is not a valid dict" error.
File 4: crud.py - Where the Real Work Happens
I introduced this file with an analogy that stuck for the rest of the session: "crud.py is the kitchen. The endpoints are the waiters. The waiter takes the order from the customer, passes it to the kitchen, and brings back the plate. Waiters don't cook."
CRUD - Create, Read, Update, Delete. One function per operation.
from sqlalchemy.orm import Session
import models, schemas
def create_student(db: Session, student: schemas.StudentCreate):
db_student = models.Student(**student.model_dump())
db.add(db_student)
db.commit()
db.refresh(db_student)
return db_student
def get_student(db: Session, student_id: int):
return db.query(models.Student).filter(
models.Student.id == student_id).first()
def get_students(db: Session):
return db.query(models.Student).all()
def update_student(db: Session, student_id: int,
data: schemas.StudentUpdate):
student = db.query(models.Student).filter(
models.Student.id == student_id).first()
if not student:
return None
updates = data.model_dump(exclude_unset=True)
for field, value in updates.items():
setattr(student, field, value)
db.commit()
db.refresh(student)
return student
def delete_student(db: Session, student_id: int):
student = db.query(models.Student).filter(
models.Student.id == student_id).first()
if not student:
return None
db.delete(student)
db.commit()
return student
The create_student function carries a pattern I told them to write down word for word: add, commit, refresh. Add stages the record. Commit saves it to disk. Refresh reloads it so you get the database-assigned id back. "You will use this exact three-step pattern every single time you create something in FastAPI. Memorise the order."
I also broke down the unpacking trick line by line, because it always raises eyebrows the first time:
db_student = models.Student(**student.model_dump())
model_dump() turns the Pydantic schema into a plain dictionary. ** unpacks that dictionary and passes each key-value pair as a keyword argument. It's shorthand for writing out every field manually.
The Moment exclude_unset=True Saved Alice
This was the part of the session everyone leaned forward for.
update_student has this line:
updates = data.model_dump(exclude_unset=True)
I explained it carefully, because getting this wrong is a genuinely common, genuinely damaging bug: "exclude_unset=True is the key to a correct update. Without it, every Optional field the user didn't send would come through as None and silently overwrite the existing data."
We tested it live. Alice Wanjiku was already in the database — name, course, grade, email, all set. We sent a PUT request with only this body:
{
"grade": 92.0
}
I asked the class to predict what would happen to Alice's name and email if we'd forgotten exclude_unset=True. A few guessed correctly - wiped to null. Then we looked at the actual response:
{
"id": 1,
"name": "Alice Wanjiku",
"course": "Python Fundamentals",
"grade": 92.0,
"email": "alice@example.com"
}
Only the grade changed. Name, course, and email - completely untouched. One keyword argument, exclude_unset=True, was the entire difference between a correct update and a destructive one.
That's the line that gets students who've already shipped something broken to a real project. They've felt this bug before they even knew its name.
File 5: main.py - Where It All Connects
"main.py is the manager. It doesn't do the work itself - it delegates to the other four files and coordinates everything."
from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
import models, schemas, crud
from database import engine, get_db
models.Base.metadata.create_all(bind=engine)
app = FastAPI(title="Student Grade Tracker",
description="Track student grades with FastAPI",
version="1.0.0")
@app.post("/students/", response_model=schemas.StudentResponse)
def create_student(student: schemas.StudentCreate,
db: Session = Depends(get_db)):
return crud.create_student(db, student)
@app.get("/students/", response_model=List[schemas.StudentResponse])
def get_students(db: Session = Depends(get_db)):
return crud.get_students(db)
@app.get("/students/{student_id}",
response_model=schemas.StudentResponse)
def get_student(student_id: int,
db: Session = Depends(get_db)):
student = crud.get_student(db, student_id)
if not student:
raise HTTPException(404, "Student not found")
return student
@app.put("/students/{student_id}",
response_model=schemas.StudentResponse)
def update_student(student_id: int,
data: schemas.StudentUpdate,
db: Session = Depends(get_db)):
student = crud.update_student(db, student_id, data)
if not student:
raise HTTPException(404, "Student not found")
return student
@app.delete("/students/{student_id}")
def delete_student(student_id: int,
db: Session = Depends(get_db)):
student = crud.delete_student(db, student_id)
if not student:
raise HTTPException(404, "Student not found")
return {"message": "Student deleted successfully"}
I pointed out the line that quietly does the most work: models.Base.metadata.create_all(bind=engine). "This looks at every class that inherits from Base and creates the matching table if it doesn't already exist. It's safe to leave running every time - it never deletes or overwrites existing data."
Then I drew attention to how clean each endpoint looks: "Four lines. The decorator says what HTTP method and URL. response_model says what shape to return. The parameters say what data to expect. The body delegates to crud.py. FastAPI, Pydantic, and SQLAlchemy each do their part invisibly."
We Tested It Live - And Broke It On Purpose
We ran:
uvicorn main:app --reload
Opened http://127.0.0.1:8000/docs. I gave the class a moment to look at the auto-generated documentation - colour-coded endpoints, all generated from code they'd just written, zero extra effort.
Then we went through the test sequence together:
-
Added Alice via POST - got back
id: 1, assigned automatically -
Added Brian via POST - got back
id: 2 - Listed all students - both showed up
-
Got Alice specifically via
GET /students/1- only Alice came back -
Updated only Alice's grade - the
exclude_unsetmoment above - Deleted Brian - confirmed gone from the full list afterward
Then we broke it deliberately. Two tests, on purpose:
Test 7 - request a student that doesn't exist:
GET /students/99
{ "detail": "Student not found" }
A clean 404. Not a crash, not a stack trace - a clear message.
Test 8 - send the wrong data type:
{
"name": "Charlie",
"course": "Python",
"grade": "very good",
"email": "charlie@example.com"
}
422 Unprocessable Entity
FastAPI rejected it before our code even ran, with a detailed error pointing at exactly which field failed. "This is why we use schemas. Pydantic catches the wrong type before our endpoint logic ever executes."
These two tests generated the most discussion of the whole session. Students realised error handling wasn't extra work they'd have to write themselves - FastAPI and Pydantic handle most of it for free, as long as the schemas are defined correctly.
What I Noticed Teaching This Session
1. The five-file board never came down. Every time someone got lost, pointing back at the five filenames re-oriented them instantly. A visible map of where you are in a multi-file build matters more than I expected.
2. exclude_unset=True is worth a dedicated pause. This is the single line in the entire project most likely to cause a real, silent bug in production code. Slowing down here, predicting the wrong outcome out loud before showing the correct one, made the lesson stick far better than just explaining it.
3. Breaking the API on purpose teaches more than building it correctly. The 404 and 422 tests got more genuine engagement than any of the successful CRUD operations. Students want to see what happens when things go wrong - show them deliberately, in a safe environment, before they hit it by accident in their own project.
4. The kitchen/waiter analogy for separation of concerns outlived the session.
What Students Are Trying Now
- Add a search query parameter to
GET /students/that filters by course name - Add a
GET /students/topendpoint returning students with grade above 80 - Make
create_studentreturn a 400 error if the email already exists - Add a
created_attimestamp column that fills automatically
Fifteen minutes of independent struggle before any solution gets shown. Even brief independent problem-solving massively improves retention compared to just watching code appear on a screen.
What We Built
Five files. One real database. Five working endpoints. Automatic validation. Automatic error handling. Interactive documentation generated entirely from the code itself.
Not a toy. Not a shortcut tutorial. A real, working REST API - built one explained line at a time, with the class testing every success path and every failure path live.
The patterns from today repeat in every FastAPI project these students will ever build: the three-step create pattern, the exclude_unset update pattern, the 404 pattern, dependency injection through get_db. Learn it once here, reuse it everywhere.
I'm a data trainer in Nairobi running a full data programme -
Python foundations → Data Science or Data Engineering specialisations.
I write weekly about what we covered, what worked, and what surprised me.
Top comments (0)