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: noid, nocreated_at, noupdated_at. Those are decided by the database, not the caller. Letting a client send its ownidis 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 of0or-5gets auto-rejected with a clean error.min_lengthdoes 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,.amountetc. as attributes]. In Pydantic v1 this wasorm_mode; v2 renamed it tofrom_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_ID → current_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. MyUsermodel requires aname, 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 cleanROLLBACK, 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=ExpenseReadruns 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, onlyExpenseRead's fields go out. -
status_code=201because 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-usedbthat 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.] -
add→commit→refresh— stage it, write it, then re-read it so the DB-generatedid/timestamps get populated on my object before I return it.
Gotcha #2: My first POST returned a
500with'ExpenseCreate' object has no attribute 'model'. The cause was a one-character typo: I'd writtenpayload.model.dump()[a dot] instead ofpayload.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. MyExpenserequiresspent_on, but my input schema didn't include it, so nothing got sent for it. Fix: addspent_ontoExpenseCreate. 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 itsNOT NULLcolumns 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, orNoneif there's no such id. -
raise HTTPException(404)is the important habit. If I letNonesail through, the code crashes trying to serialize nothing → a500, which wrongly implies I broke. Instead I deliberately raise a 404 Not Found: "your request was fine, that thing just isn't here."raise[notreturn] 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 = Nonemakes a field optional — "either aDecimalor nothing." [Older tutorials writeOptional[Decimal]; same meaning,| Noneis the modern style.] Note the validation still rides along: ifamountis 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 asNoneand 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 ofexpense.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 typeddetails=instead ofdetail=inHTTPException— a bad keyword makes the exception itself blow up. Second, and sneakier: when I hit Execute in/docswithout editing the body, myamountcame 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 storingNumericas 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_unsetcan'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.
-
422vs404is a real distinction: bad shape vs valid-but-absent. -
raise HTTPException(...)[notreturn] 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
Dependsdependency withyield/finally— never leak a connection. - Money stays
Decimalall 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.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.