DEV Community

Navas Herbert
Navas Herbert

Posted on

We Built a Real API Live in Class - Here's How the Grade Tracker Session Went


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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Added Alice via POST - got back id: 1, assigned automatically
  2. Added Brian via POST - got back id: 2
  3. Listed all students - both showed up
  4. Got Alice specifically via GET /students/1 - only Alice came back
  5. Updated only Alice's grade - the exclude_unset moment above
  6. 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
Enter fullscreen mode Exit fullscreen mode
{ "detail": "Student not found" }
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode
422 Unprocessable Entity
Enter fullscreen mode Exit fullscreen mode

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/top endpoint returning students with grade above 80
  • Make create_student return a 400 error if the email already exists
  • Add a created_at timestamp 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)