🔧 AI Nachrichten Major AI platforms go down in unprecedented simultaneous outage(03.09.2026 um 17:34 Uhr)
🔧 AI Nachrichten ChatGPT, Claude, and Grok Down? Users Report Widespread Outages(03.09.2026 um 19:14 Uhr)
🔧 AI Nachrichten OpenAI Launches GPT-6 Astra, Says We May Have Entered the AGI Era(03.09.2026 um 22:08 Uhr)
🔧 AI Nachrichten Claude Comes to CarPlay as Fifth Major AI Chatbot App(05.09.2026 um 05:31 Uhr)
🔧 AI Nachrichten OpenAI’s GPT-6 Astra Is AGI, Says NVIDIA CEO Jensen Huang(07.09.2026 um 06:31 Uhr)
🔧 AI Nachrichten Blame AI companies for Mac mini and Mac Studio shortage(31.08.2026 um 10:32 Uhr)
🔧 AI Nachrichten Major AI platforms go down in unprecedented simultaneous outage(03.09.2026 um 17:34 Uhr)
🔧 AI Nachrichten ChatGPT, Claude, and Grok Down? Users Report Widespread Outages(03.09.2026 um 19:14 Uhr)
🔧 AI Nachrichten OpenAI Launches GPT-6 Astra, Says We May Have Entered the AGI Era(03.09.2026 um 22:08 Uhr)
🔧 AI Nachrichten Claude Comes to CarPlay as Fifth Major AI Chatbot App(05.09.2026 um 05:31 Uhr)
🔧 AI Nachrichten OpenAI’s GPT-6 Astra Is AGI, Says NVIDIA CEO Jensen Huang(07.09.2026 um 06:31 Uhr)
🔧 AI Nachrichten Blame AI companies for Mac mini and Mac Studio shortage(31.08.2026 um 10:32 Uhr)

🔧 Programmierung 🕛 kürzlich 14 Min Lesezeit
0

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

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht

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:




CODE
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)






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.




CODE
DEV_USER_ID = 5  # TEMP: Phase 3 replaces this with the authenticated user






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:




CODE
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()






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:




CODE
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






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.




CODE
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








  • 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:




CODE
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









CODE
@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








  • 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:




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








  • 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.

Vollständiger Original-Bericht
Ausführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
3 Quellen
GPT-6 Astra Release Today? OpenAI’s Next Major AI Model Is Almost Here
1 Quelle
Apple accuses OpenAI of destroying evidence as trade-secrets fight intensifies
1 Quelle
Major AI platforms go down in unprecedented simultaneous outage
Ähnliche Beiträge
🔍 Verwandte News

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

Thematisch verwandte Begriffe: Teaching, Backend, Listen, Reply · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...