DEV Community

Azhar Alvi
Azhar Alvi

Posted on

Teaching My Backend to Listen and Reply — FastAPI CRUD, Phase 2

Validate what comes in, shape what goes out, and give every outcome its proper status code.

So Phase 1 gave my app a memory — a real database that remembers users and expenses even after a restart. Small problem: the only way to actually talk to it was a Python script I ran by hand. The app could remember things, but it was mute. Nobody on the internet could add an expense, list their spending, or delete a typo.

Phase 2 fixes that. This is where the app grows a mouth — real HTTP endpoints you hit through the browser. The buzzword is CRUD: Create, Read, Update, Delete. The four things basically every app does to data. I went in thinking "it's just four functions, how hard can it be" and came out having learned about request/response contracts, dependency injection, and roughly six different HTTP status codes — mostly by triggering them wrong first. Let me dump what I learned [and the parts that tripped me up, because there were, uh, several].

The Structure is as follows. Lets call it PHASE 2 — The Endpoints:

  • Set up the request/response contracts (two Pydantic schemas: one for input, one for output)
  • Build a session dependency so every request gets a safe, auto-closing DB connection
  • POST — create an expense
  • GET — list them all + fetch one [with a proper 404]
  • PATCH — update just the fields that changed
  • DELETE — remove one cleanly

First, the two new ideas [and no, nothing to install this time]

Phase 1 had two new libraries. Phase 2 has two new ideas — and the nice part is there's nothing to pip install, because Pydantic already ships inside FastAPI. [Which means requirements.txt didn't change this phase. Still worth knowing the freeze habit is only for phases where you actually install something.]

  • Pydantic — the bouncer at the door. Your SQLAlchemy models describe how data is stored; Pydantic describes the shape data must have to cross the border of your API. It checks types, rejects garbage, and turns raw JSON into a clean Python object before it gets anywhere near your database.
  • Dependency injection — a fancy name for a simple deal. Instead of each endpoint opening its own database session and hoping to remember to close it, you write "how to get a session" once, and every endpoint just declares "I need one." FastAPI runs it for you and cleans up after. You'll see it as Depends(...).

Step 1: Two contracts — one for input, one for output

The single biggest lesson of this phase: you do not use one model for both the data coming in and the data going out. Two separate Pydantic schemas, on purpose. This lives in a new file, schemas.py:

from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field


class ExpenseCreate(BaseModel):
    """What the CLIENT is allowed to send."""
    amount: Decimal = Field(gt=0)
    description: str = Field(min_length=1, max_length=255)
    spent_on: date


class ExpenseRead(BaseModel):
    """What the SERVER sends back — includes DB-generated fields."""
    id: int
    amount: Decimal
    description: str
    spent_on: date
    created_at: datetime
    updated_at: datetime

    model_config = ConfigDict(from_attributes=True)
Enter fullscreen mode Exit fullscreen mode

Why two, and not one? Because input and output have genuinely different jobs:

  • ExpenseCreate (input) contains only the fields a client is allowed to send. Notice what's missing: no id, no created_at, no updated_at. Those are decided by the database, not the caller. Letting a client send its own id is a classic foot-gun.
  • ExpenseRead (output) is what you send back, and it does include those server-generated fields.
  • Field(gt=0) is Pydantic validating for me: an amount of 0 or -5 gets auto-rejected with a clean error. min_length does the same for text. The bouncer, doing its job.
  • model_config = ConfigDict(from_attributes=True) is the one line that lets Pydantic build the output straight from my SQLAlchemy object [reading .id, .amount etc. as attributes]. In Pydantic v1 this was orm_mode; v2 renamed it to from_attributes. Just know it exists — you'll feel why it matters in Step 3.

And yep, amount stays Decimal here too. Same Phase 1 rule: money never touches a float, not even at the API boundary.

A quick detour: who owns this expense?

Before I could create anything, I hit a wall that's actually a good lesson. My Expense has a user_id foreign key — the database will reject any expense that doesn't point at a real user. But I haven't built login yet [that's Phase 3]. So… who's the owner?

The honest answer: I cheated, on purpose, and wrote down that I cheated.

DEV_USER_ID = 5  # TEMP: Phase 3 replaces this with the authenticated user
Enter fullscreen mode Exit fullscreen mode

I seeded one "dev user" and hardcoded its id as the owner of every expense. In a real app the owner comes from whoever's logged in. But the whole point of learning one thing at a time is not tangling auth into CRUD. So I structured it so Phase 3 is a one-line swap: DEV_USER_IDcurrent_user.id. Shortcut taken, shortcut labeled.

Gotcha #1: My first attempt to seed the dev user blew up with NOT NULL constraint failed: users.name. My User model requires a name, and I hadn't given one. Same rule as always — if a column is required, you have to supply it. [Bonus: the failed insert did a clean ROLLBACK, so no half-broken row got saved. The transaction safety I read about in Phase 1, actually doing its thing.]

Step 2: The session dependency [write it once, never leak a connection]

Every endpoint needs a database session, and every session must close afterward — even if the request explodes halfway through. So instead of copy-pasting that logic into five endpoints, it goes in database.py once:

from collections.abc import Generator
from sqlalchemy.orm import Session


def get_db() -> Generator[Session, None, None]:
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()
Enter fullscreen mode Exit fullscreen mode

The weird-looking part is the yield inside a try/finally. A normal function returns and it's done. This one yields the session out to my endpoint, pauses while the endpoint runs, then comes back and runs the finally block no matter what. That finally is the whole point: db.close() is guaranteed to run on success and on error, so a session can never leak. FastAPI understands this pattern and drives it for me. [I don't fully grok generators yet, and that's fine — for now: yield = "hand it out, then come back and clean up."]

Step 3: POST — create an expense [it all comes together]

This is where Pydantic, the session dependency, and my model finally click into one endpoint. It goes in main.py, where app lives:

from fastapi import Depends, status
from sqlalchemy.orm import Session
from database import get_db
from models import Expense
from schemas import ExpenseCreate, ExpenseRead


@app.post("/expenses", response_model=ExpenseRead, status_code=status.HTTP_201_CREATED)
def create_expense(payload: ExpenseCreate, db: Session = Depends(get_db)):
    expense = Expense(**payload.model_dump(), user_id=DEV_USER_ID)
    db.add(expense)
    db.commit()
    db.refresh(expense)
    return expense
Enter fullscreen mode Exit fullscreen mode

Every line is pulling weight, and it took me a minute to appreciate that:

  • The @app.post(...) decorator is the wiring. @ means "attach behavior to this function" — here, "when a POST hits /expenses, run me." I write the logic; the decorator handles reading the request and sending the response.
  • response_model=ExpenseRead runs whatever I return through the output schema before sending it. This is what stops internal fields from ever leaking — even if my model grew a secret column tomorrow, only ExpenseRead's fields go out.
  • status_code=201 because REST says "created" is a 201, not a plain 200. Small thing, correct thing.
  • payload: ExpenseCreate — just by type-hinting the parameter, FastAPI auto-reads the JSON body, validates it against the schema, and hands me a clean object. I never touch raw JSON.
  • Depends(get_db) — the Step 2 dependency in action. I just get a ready-to-use db that closes itself.
  • `Expense(payload.model_dump(), user_id=DEV_USER_ID)** — model_dump() turns the validated object into a dict, **` unpacks it into the model, and I tack on the owner. [That's the one line Phase 3 will change.]
  • addcommitrefresh — stage it, write it, then re-read it so the DB-generated id/timestamps get populated on my object before I return it.

Gotcha #2: My first POST returned a 500 with 'ExpenseCreate' object has no attribute 'model'. The cause was a one-character typo: I'd written payload.model.dump() [a dot] instead of payload.model_dump() [an underscore]. model_dump() is a single Pydantic v2 method name, not two things chained with a dot. [If you see .dict() in old tutorials, that's the v1 name for the same thing.]

Gotcha #3: Next 500: NOT NULL constraint failed: expenses.spent_on. My Expense requires spent_on, but my input schema didn't include it, so nothing got sent for it. Fix: add spent_on to ExpenseCreate. The rule crystallized here — every required DB column must exist in the input schema. Whack-a-mole ends the moment you check your model for all its NOT NULL columns up front.

Step 4: GET — list them all, and fetch one [meet the 404]

Two reads, two conventions: GET /expenses returns a list; GET /expenses/{id} returns one.

from typing import List
from fastapi import HTTPException
from sqlalchemy import select


@app.get("/expenses", response_model=List[ExpenseRead])
def list_expenses(db: Session = Depends(get_db)):
    return db.scalars(select(Expense)).all()


@app.get("/expenses/{expense_id}", response_model=ExpenseRead)
def get_expense(expense_id: int, db: Session = Depends(get_db)):
    expense = db.get(Expense, expense_id)
    if expense is None:
        raise HTTPException(status_code=404, detail="Expense not found")
    return expense
Enter fullscreen mode Exit fullscreen mode
  • db.scalars(select(Expense)).all() is the same SQLAlchemy 2.0 read from Phase 1, just returning the whole list.
  • db.get(Expense, id) is the shortcut for "fetch one by primary key" — returns the object, or None if there's no such id.
  • raise HTTPException(404) is the important habit. If I let None sail through, the code crashes trying to serialize nothing → a 500, which wrongly implies I broke. Instead I deliberately raise a 404 Not Found: "your request was fine, that thing just isn't here." raise [not return] stops the function dead and sends the error.

And here's the coolest thing I learned this phase, entirely by fat-fingering the docs:

  • Ask for /expenses/abc [a string where an int belongs] → you get a 422. FastAPI checks the path type before your function even runs and decides the request itself is malformed.
  • Ask for /expenses/999999 [a valid integer that doesn't exist] → you get a 404. The request was well-formed, so the function runs, finds nothing, and raises 404.

So: 422 = "your request was malformed" vs 404 = "your request was fine, the resource doesn't exist." I built both behaviors correctly without even meaning to. That distinction is going straight into my mental model.

Gotcha #4: My list endpoint gave a "cannot fetch" until I noticed I'd dropped the leading / in the route path. Added the slash, instantly worked. The kind of bug that's invisible for ten minutes and obvious the second you spot it.

Step 5: PATCH — change only what changed

Update has two flavors, and picking the right one matters:

  • PUT = "replace the whole resource." The client must resend every field; anything left out gets wiped.
  • PATCH = "apply a partial change." Send only the fields you're changing; everything else stays.

For fixing a typo in one expense, forcing the user to resend amount + description + date [PUT's demand] is clumsy. So I built PATCH. It needs its own schema where every field is optional:

class ExpenseUpdate(BaseModel):
    amount: Decimal | None = Field(default=None, gt=0)
    description: str | None = Field(default=None, min_length=1, max_length=255)
    spent_on: date | None = None
Enter fullscreen mode Exit fullscreen mode
@app.patch("/expenses/{expense_id}", response_model=ExpenseRead)
def update_expense(expense_id: int, payload: ExpenseUpdate, db: Session = Depends(get_db)):
    expense = db.get(Expense, expense_id)
    if expense is None:
        raise HTTPException(status_code=404, detail="Expense not found")

    update_data = payload.model_dump(exclude_unset=True)
    for field, value in update_data.items():
        setattr(expense, field, value)

    db.commit()
    db.refresh(expense)
    return expense
Enter fullscreen mode Exit fullscreen mode
  • X | None = None makes a field optional — "either a Decimal or nothing." [Older tutorials write Optional[Decimal]; same meaning, | None is the modern style.] Note the validation still rides along: if amount is sent, it must still be > 0.
  • model_dump(exclude_unset=True) is the heart of PATCH — it gives me a dict of only the fields the client actually sent, ignoring the untouched ones. Without it, an omitted field would come through as None and I'd accidentally erase existing data. This one flag avoids the whole trap.
  • setattr(expense, field, value) sets an attribute by a name held in a variable — the dynamic version of expense.amount = .... Since I don't know in advance which fields arrived, I loop and set each one.

Gotcha #5: Two things bit me here. First, another 500, because I typed details= instead of detail= in HTTPException — a bad keyword makes the exception itself blow up. Second, and sneakier: when I hit Execute in /docs without editing the body, my amount came back as some huge float. Turns out Swagger pre-fills the request body with placeholder sample values — clicking Execute doesn't send "nothing," it sends those placeholders. So PATCH dutifully saved a giant number. [Combined with SQLite storing Numeric as float, it looked even uglier — that tidies up on Postgres later.] Lesson: to test a partial update, edit the body down to just the field you mean, e.g. {"amount": 99.99}. exclude_unset can't exclude what Swagger auto-filled for you.

Step 6: DELETE — remove one, return nothing

Last verb, and it introduces one more status code:

@app.delete("/expenses/{expense_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_expense(expense_id: int, db: Session = Depends(get_db)):
    expense = db.get(Expense, expense_id)
    if expense is None:
        raise HTTPException(status_code=404, detail="Expense not found")
    db.delete(expense)
    db.commit()
    return None
Enter fullscreen mode Exit fullscreen mode
  • 204 No Content is the convention for a successful delete: "it worked, and there's deliberately nothing to send back." That's why this endpoint has no response_model — there's no resource left to shape.
  • db.delete() + db.commit() is the destructive bit. Once committed, the row is gone — no undo. So I tested it on a throwaway expense, deleted it [204], then GET-ed the same id to confirm it returned a clean 404. Gone means gone.

One non-code head-scratcher [worth writing down]

At one point 127.0.0.1:8000/docs took ages to load and I assumed my code was broken. It wasn't. /docs is Swagger UI, and by default FastAPI tells your browser to download Swagger's JS/CSS from a public CDN — which crawls on a slow or corporate/proxied network. My actual API was instant [hitting /expenses directly proved it]. Nice reminder: a slow docs page is not a slow app. [The production fix is self-hosting those assets, but that's polish for a later hardening phase.]

Stuff I want to remember [the honest takeaways]

  • One model for input, a different one for output. Never accept or expose fields you shouldn't.
  • Every required DB column must appear in your input schema — check the model up front instead of playing 500-error whack-a-mole.
  • Status codes carry meaning: 201 create, 200 read/update, 204 delete, 404 missing resource, 422 malformed request.
  • 422 vs 404 is a real distinction: bad shape vs valid-but-absent.
  • raise HTTPException(...) [not return] is how you send an error and stop.
  • model_dump() is one method with an underscore. model_dump(exclude_unset=True) is what makes PATCH safe.
  • Write your DB-session logic once as a Depends dependency with yield/finally — never leak a connection.
  • Money stays Decimal all the way to the API edge.
  • db.delete() + commit() is permanent. Test destructive stuff on junk data.
  • Swagger pre-fills placeholder values — a blind "Execute" can overwrite real data.
  • When you cut a corner [like hardcoding the owner], write it down and leave yourself the one-line path back.

Next up: Phase 3, Authentication — where that hardcoded DEV_USER_ID finally retires. The app has a memory and a mouth; now it needs a lock on the door: registration, hashed passwords, JWT logins, and making sure no user can peek at another's expenses. If Phase 2 gave the app a mouth, Phase 3 gives it a bouncer. See you there.

Top comments (0)