<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Azhar Alvi </title>
    <description>The latest articles on DEV Community by Azhar Alvi  (@silentcarry).</description>
    <link>https://dev.to/silentcarry</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4032488%2F41bd09e7-acef-435e-8ec5-5d489ca389d1.gif</url>
      <title>DEV Community: Azhar Alvi </title>
      <link>https://dev.to/silentcarry</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/silentcarry"/>
    <language>en</language>
    <item>
      <title>Phase 8 — Making It Trustworthy: Hardening a FastAPI App with an Audit, a Test Net, and a Logging Bug I Typed Twice</title>
      <dc:creator>Azhar Alvi </dc:creator>
      <pubDate>Sun, 16 Aug 2026 13:58:28 +0000</pubDate>
      <link>https://dev.to/silentcarry/phase-8-making-it-trustworthy-hardening-a-fastapi-app-with-an-audit-a-test-net-and-a-logging-11c8</link>
      <guid>https://dev.to/silentcarry/phase-8-making-it-trustworthy-hardening-a-fastapi-app-with-an-audit-a-test-net-and-a-logging-11c8</guid>
      <description>&lt;p&gt;Phase 7 gave the app a brain. Phase 8 was about making it &lt;em&gt;trustworthy&lt;/em&gt; — the unglamorous gate between "works on my machine" and "safe to put a URL in front of."&lt;/p&gt;

&lt;p&gt;No new features. Instead: a security audit that probed my own API for leaks, a global exception handler so unexpected errors stop leaking internals, a five-test pytest suite that turns every manual check into a permanent one, and a git-history secrets sweep. Plus the war story — a logging placeholder I fat-fingered the same way &lt;em&gt;twice&lt;/em&gt;, a test that silently never ran, and a login that failed because of one capital letter.&lt;/p&gt;

&lt;p&gt;Spoiler: the app had zero leaks. Almost every bug this phase was in the &lt;em&gt;terminal&lt;/em&gt;, not the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Index
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Where we left off&lt;/li&gt;
&lt;li&gt;The plan: prove it, then protect it&lt;/li&gt;
&lt;li&gt;Step 1 — The warm-up: a &lt;code&gt;/health&lt;/code&gt; endpoint&lt;/li&gt;
&lt;li&gt;Step 2 — The audit: probe my own API for leaks&lt;/li&gt;
&lt;li&gt;Step 3 — The safety net: a global exception handler&lt;/li&gt;
&lt;li&gt;Step 4 — The test net: turning manual proofs into permanent ones&lt;/li&gt;
&lt;li&gt;Step 5 — The isolation test: an in-memory database that lies convincingly&lt;/li&gt;
&lt;li&gt;Step 6 — Pagination: putting a ceiling on an unbounded list&lt;/li&gt;
&lt;li&gt;Step 7 — The secrets sweep: is anything sensitive in my git history?&lt;/li&gt;
&lt;li&gt;The war story: the tool does what you type, not what you mean&lt;/li&gt;
&lt;li&gt;Thinking like an attacker&lt;/li&gt;
&lt;li&gt;Learning shortcut vs. production&lt;/li&gt;
&lt;li&gt;Key habits to keep&lt;/li&gt;
&lt;li&gt;Next up: Phase 9&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where we left off
&lt;/h2&gt;

&lt;p&gt;Phase 7b ended with the seam holding: a category column, a rules engine, and a Gemini-backed &lt;code&gt;LLMCategorizer&lt;/code&gt; living behind the same &lt;code&gt;Categorizer&lt;/code&gt; contract — validated, cached, and with a fallback that was finally loud about failing.&lt;/p&gt;

&lt;p&gt;I closed that post nagging at three things: the cache dies on restart, the model name is a drifting alias, and my free tier can't afford to be the default. All real. All &lt;em&gt;enhancements&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;But there was a bigger question I'd been walking past: &lt;strong&gt;is this thing actually safe to deploy?&lt;/strong&gt; I had auth, I had owner-scoped queries, I &lt;em&gt;believed&lt;/em&gt; users were isolated — but belief isn't proof. Phase 8 is where I stop believing and start verifying.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plan: prove it, then protect it
&lt;/h2&gt;

&lt;p&gt;The temptation was to jump straight to "add security stuff." I didn't. You can't harden what you haven't observed, so I ordered the work from &lt;em&gt;lowest risk and highest evidence&lt;/em&gt; outward:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Why this order&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;A &lt;code&gt;/health&lt;/code&gt; endpoint&lt;/td&gt;
&lt;td&gt;Purely additive warm-up; also the thing my host will ping in Phase 9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Audit the real API for leaks&lt;/td&gt;
&lt;td&gt;Get evidence before writing a single fix — don't guess what leaks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Global exception handler&lt;/td&gt;
&lt;td&gt;The one gap the audit &lt;em&gt;couldn't&lt;/em&gt; test: the unexpected 500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4–5&lt;/td&gt;
&lt;td&gt;A pytest suite&lt;/td&gt;
&lt;td&gt;Turn every manual proof into a permanent, re-runnable one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Pagination&lt;/td&gt;
&lt;td&gt;Put a ceiling on an unbounded list before it becomes a problem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Secrets / git-history sweep&lt;/td&gt;
&lt;td&gt;The one item with real downside if skipped before going public&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The principle from Phase 7 still drives it: &lt;strong&gt;observe first, then change one thing at a time.&lt;/strong&gt; When something breaks, I want to know it broke in the thing I just touched.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — The warm-up: a &lt;code&gt;/health&lt;/code&gt; endpoint
&lt;/h2&gt;

&lt;p&gt;Every real route in my app is auth-gated behind &lt;code&gt;get_current_user&lt;/code&gt;. That's correct for expenses — but a monitoring system &lt;em&gt;can't log in&lt;/em&gt;. So I need exactly one deliberately public, side-effect-free route that answers "are you alive?" with a fast, boring &lt;code&gt;200&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/health&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;health_check&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Idea&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No &lt;code&gt;Depends&lt;/code&gt;, no auth&lt;/td&gt;
&lt;td&gt;The &lt;em&gt;absence&lt;/em&gt; is the point — a monitor hits it without a token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No DB call&lt;/td&gt;
&lt;td&gt;It answers "is the process up," not "is every subsystem healthy." A deeper check is a conscious later step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Returns a plain &lt;code&gt;dict&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;FastAPI serializes it to JSON automatically — no &lt;code&gt;response_model&lt;/code&gt; needed for something this trivial&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Additive, zero risk, and it's the exact URL Phase 9's host will hit to confirm the deploy is up before I even try to log in. Good warm-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — The audit: probe my own API for leaks
&lt;/h2&gt;

&lt;p&gt;This is the part I'd skipped in every previous project: actually attacking my own endpoints and &lt;em&gt;recording what they return&lt;/em&gt;. Not "I think it 404s" — curl it and read the status line.&lt;/p&gt;

&lt;p&gt;I grabbed a token once, stashed it in a shell variable, and fired four deliberately-bad requests. Here's the evidence table that came out:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Probe&lt;/th&gt;
&lt;th&gt;Expected&lt;/th&gt;
&lt;th&gt;Actual&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;POST expense, negative amount&lt;/td&gt;
&lt;td&gt;422&lt;/td&gt;
&lt;td&gt;422&lt;/td&gt;
&lt;td&gt;Input validation holds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;GET &lt;code&gt;/expenses&lt;/code&gt;, garbage token&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;Auth boundary holds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;GET a non-existent expense id&lt;/td&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;td&gt;Not-found path is clean&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;GET another user's expense id&lt;/td&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;td&gt;Cross-user isolation holds&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things I want to call out, because they're &lt;em&gt;design&lt;/em&gt; wins, not luck:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Probe #1 dies at the schema, not the database.&lt;/strong&gt; My &lt;code&gt;ExpenseCreate&lt;/code&gt; declares &lt;code&gt;amount&lt;/code&gt; with &lt;code&gt;gt=0&lt;/code&gt;, so Pydantic rejects a negative amount before my route body ever runs. Nothing gets written. Validation at the edge means the dangerous code never executes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Probe #4 returns 404, not 403 — on purpose.&lt;/strong&gt; My query filters by &lt;em&gt;both&lt;/em&gt; &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;user_id == current_user.id&lt;/code&gt;, so a row I don't own comes back as &lt;code&gt;None&lt;/code&gt; → 404. A &lt;code&gt;403 Forbidden&lt;/code&gt; would be the wrong choice here: it would confirm "this expense exists, you're just not allowed to see it," which leaks the record's existence. &lt;code&gt;404&lt;/code&gt; says "nothing here for you" and reveals nothing.&lt;/p&gt;

&lt;p&gt;The candid bit: the "raw stack trace" fear I'd been carrying is mostly unfounded on FastAPI. It doesn't put tracebacks in the HTTP response by default — it returns a generic 500 and logs the trace server-side. Tracebacks only leak into the body if you run with debug on. So the real production job isn't "hide the trace," it's "make sure debug stays off, and add a net for the unexpected." Which is Step 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — The safety net: a global exception handler
&lt;/h2&gt;

&lt;p&gt;The audit found zero leaks — but it also &lt;em&gt;couldn't&lt;/em&gt; test one thing, because nothing in my app currently throws an unhandled error. Production code eventually will. When it does, I want a deliberate, consistent response — not whatever Starlette's default is.&lt;/p&gt;

&lt;p&gt;FastAPI already handles two error families: my intentional &lt;code&gt;HTTPException&lt;/code&gt;s (401/404/409) and validation errors (422). What it doesn't handle is the &lt;em&gt;unexpected&lt;/em&gt; — a bug, a &lt;code&gt;None&lt;/code&gt; where I assumed a value. A catch-all lets me log the full traceback &lt;strong&gt;server-side&lt;/strong&gt; with a correlation id, and return a clean generic body to the client.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.exception_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;unhandled_exception_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;error_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nb"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unhandled error [%s] on %s %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JSONResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;detail&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Internal server error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;error_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concept&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@app.exception_handler(Exception)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Catches only what a &lt;em&gt;more specific&lt;/em&gt; handler didn't — my &lt;code&gt;HTTPException&lt;/code&gt;s and 422s keep their exact status codes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;logger.exception(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Logs at ERROR level &lt;em&gt;with&lt;/em&gt; the full traceback — the server-side record&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;uuid4().hex[:8]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A short correlation id, printed in both the log and the response&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generic &lt;code&gt;{"detail", "error_id"}&lt;/code&gt; body&lt;/td&gt;
&lt;td&gt;The client sees a reference code to quote to support — never internals&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The critical nuance is that first row. A handler on the broad &lt;code&gt;Exception&lt;/code&gt; type does &lt;em&gt;not&lt;/em&gt; swallow my deliberate &lt;code&gt;HTTPException&lt;/code&gt;s — if it did, every 404 would turn into a 500. So the design is a floor under the &lt;em&gt;unexpected&lt;/em&gt;, not a replacement for my existing errors.&lt;/p&gt;

&lt;p&gt;And I proved it — not by faith, by a throwaway &lt;code&gt;/debug/boom&lt;/code&gt; route that raised on demand, watched the handler catch it, then deleted the route. The 404 regression check afterward confirmed the catch-all left my intentional errors alone. (Getting that log line to actually format is its own war story below.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — The test net: turning manual proofs into permanent ones
&lt;/h2&gt;

&lt;p&gt;Everything I proved in Step 2 with curl — the 422, the 401, the 404, the isolation — evaporates the moment I change code. A test turns a one-time proof into a permanent guarantee, and it's the single most credible signal to a stranger reading the repo that the app actually works.&lt;/p&gt;

&lt;p&gt;FastAPI's &lt;code&gt;TestClient&lt;/code&gt; calls the app &lt;strong&gt;in-process&lt;/strong&gt; — no uvicorn, no network, fast and isolated. I proved the harness with one trivial &lt;code&gt;/health&lt;/code&gt; test first (prove the tooling before you trust the result), then added the real ones.&lt;/p&gt;

&lt;p&gt;The interesting piece is testing &lt;em&gt;authenticated&lt;/em&gt; paths without a live login, using &lt;code&gt;dependency_overrides&lt;/code&gt; — and the trap that comes with it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;as_authenticated_user&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_fake_user&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Test User&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashed_password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dependency_overrides&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_fake_user&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dependency_overrides&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concept&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dependency_overrides&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Swaps a real dependency for a fake — auth is satisfied, the route runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A &lt;strong&gt;fixture&lt;/strong&gt;, not a global&lt;/td&gt;
&lt;td&gt;The trap: a global override poisons &lt;em&gt;every&lt;/em&gt; test, silently breaking the 401 test&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;yield&lt;/code&gt; split&lt;/td&gt;
&lt;td&gt;Everything before is setup, everything after is teardown — the &lt;code&gt;.clear()&lt;/code&gt; is what keeps tests isolated&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The proof that the teardown works: after adding the fixture, my &lt;code&gt;test_expenses_requires_auth&lt;/code&gt; test &lt;em&gt;still&lt;/em&gt; returned 401. If the override had leaked, it would've gotten the fake user and failed. Isolation intact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — The isolation test: an in-memory database that lies convincingly
&lt;/h2&gt;

&lt;p&gt;The highest-value test is the one thing I'd only proven by hand: cross-user isolation. But it &lt;em&gt;writes&lt;/em&gt; to the database, so it can't touch my real &lt;code&gt;expenses.db&lt;/code&gt;. That means a proper test-database fixture — and the trickiest concept of the whole phase.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_db&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sqlite://&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;connect_args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;check_same_thread&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;poolclass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;StaticPool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;TestingSessionLocal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sessionmaker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;autoflush&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;autocommit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_override_get_db&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TestingSessionLocal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;
        &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dependency_overrides&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_override_get_db&lt;/span&gt;
    &lt;span class="n"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TestingSessionLocal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;seed&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dependency_overrides&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drop_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;StaticPool&lt;/code&gt; line is the one to internalize. &lt;code&gt;TestClient&lt;/code&gt; runs requests in a thread pool, and a normal in-memory SQLite database is &lt;em&gt;per-connection&lt;/em&gt; — a second connection sees an &lt;em&gt;empty&lt;/em&gt; database, so my seeded rows would vanish. &lt;code&gt;poolclass=StaticPool&lt;/code&gt; forces SQLAlchemy to reuse &lt;strong&gt;one single connection&lt;/strong&gt; for everything, so the schema and seeded data persist across request threads. Without it, this test fails in a baffling way.&lt;/p&gt;

&lt;p&gt;The test itself seeds two users and one expense owned by user2, then makes the &lt;em&gt;same&lt;/em&gt; request as each identity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_cross_user_isolation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;test_db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# ... seed user1, user2, and an expense owned by user2 ...
&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dependency_overrides&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;u2_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/expenses/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;expense_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;      &lt;span class="c1"&gt;# the control
&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dependency_overrides&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;u1_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;other&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/expenses/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;expense_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;      &lt;span class="c1"&gt;# the guarantee
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;200&lt;/code&gt; assertion is the &lt;em&gt;control&lt;/em&gt;, and it's what stops this from being a false pass. It proves the row genuinely exists and is readable — so the following &lt;code&gt;404&lt;/code&gt; can &lt;em&gt;only&lt;/em&gt; mean my owner-scoped query hid it from a different user. Same id, two identities, two outcomes. That's the isolation guarantee, now permanent.&lt;/p&gt;

&lt;p&gt;(This test almost didn't run at all — see the war story.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6 — Pagination: putting a ceiling on an unbounded list
&lt;/h2&gt;

&lt;p&gt;My &lt;code&gt;list_expenses&lt;/code&gt; was returning &lt;em&gt;every&lt;/em&gt; row for the user with no ceiling. Fine at 12 rows, a latent problem at 10,000. Two query params fix it — with validation at the edge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/expenses&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ExpenseRead&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;list_expenses&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;expenses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spent_on&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;expenses&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The non-obvious part: &lt;strong&gt;offset/limit is meaningless without a deterministic &lt;code&gt;ORDER BY&lt;/code&gt;.&lt;/strong&gt; SQLite makes no promise about row order unless you ask, so "skip 20, take 20" could overlap or drop rows between requests if the order shifts. Pagination and ordering are a package deal — hence the &lt;code&gt;order_by&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The actual hardening is &lt;code&gt;le=100&lt;/code&gt;: a caller asking for &lt;code&gt;limit=999&lt;/code&gt; gets a clean 422 instead of defeating the whole point. And I named the behavior change out loud — the list now returns &lt;em&gt;max 50 by default&lt;/em&gt; where it used to return everything — so future-me isn't surprised when the frontend eventually needs a "load more."&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7 — The secrets sweep: is anything sensitive in my git history?
&lt;/h2&gt;

&lt;p&gt;The one mental model to never forget: &lt;strong&gt;git history is a security camera that never stops recording, and deleting a file does not delete its past.&lt;/strong&gt; If a secret appeared in &lt;em&gt;any&lt;/em&gt; commit — even one I later "undid" — it's still recoverable and will go public the moment I push.&lt;/p&gt;

&lt;p&gt;So a secrets audit is really three questions, and people who get burned only ask the first:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;What it proves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tracked &lt;em&gt;now&lt;/em&gt;?&lt;/td&gt;
&lt;td&gt;`git ls-files \&lt;/td&gt;
&lt;td&gt;grep -iE "env\&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File ever committed?&lt;/td&gt;
&lt;td&gt;{% raw %}&lt;code&gt;git log --all --oneline -- backend/.env&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Past tense, by filename&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;em&gt;Value&lt;/em&gt; ever committed, any file?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git log --all --oneline -S "actual-value"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Past tense, by content&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three came back clean. But question 1 taught a bonus lesson: it matched &lt;code&gt;backend/alembic/env.py&lt;/code&gt; — Alembic's migration script, which just has the letters "env" in its name. A total false positive. &lt;strong&gt;A filter matches strings, not meaning&lt;/strong&gt; — so you always eyeball &lt;em&gt;what&lt;/em&gt; matched instead of trusting the filter.&lt;/p&gt;

&lt;p&gt;The through-line from question 3: a secret leaks by &lt;em&gt;content&lt;/em&gt;, not filename. Someone hardcodes &lt;code&gt;SECRET_KEY = "..."&lt;/code&gt; into a &lt;code&gt;.py&lt;/code&gt; "just to test," commits it, then later moves it to &lt;code&gt;.env&lt;/code&gt;. The &lt;code&gt;.env&lt;/code&gt; history is spotless — but the value is in that old commit forever. Only the pickaxe (&lt;code&gt;git log -S&lt;/code&gt;) searching for the actual value catches that.&lt;/p&gt;

&lt;p&gt;(Honest note I wrote down: a clean pickaxe only proves the &lt;em&gt;current&lt;/em&gt; values were never committed. If a secret has ever lived somewhere I'm unsure about, the bulletproof move before a public deploy is to rotate it anyway. Rotation beats archaeology.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The war story: the tool does what you type, not what you mean
&lt;/h2&gt;

&lt;p&gt;Phase 7b's theme was "everything that isn't your code lying to you." Phase 8's theme was quieter and more humbling: &lt;strong&gt;my own typing, and tools doing &lt;em&gt;exactly&lt;/em&gt; what I typed instead of what I meant.&lt;/strong&gt; Almost none of it was in the application code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha 1 — the logging placeholder I fat-fingered twice
&lt;/h3&gt;

&lt;p&gt;My shiny new exception handler ran, the client got its 500 — but the server log was garbage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Message: 'Unhandled error [$s] on $s %s'
Arguments: ('a06f2a97', 'GET', '/debug/boom')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;See the &lt;code&gt;$s&lt;/code&gt;? I'd typed &lt;code&gt;$s&lt;/code&gt; instead of &lt;code&gt;%s&lt;/code&gt;. Python's &lt;code&gt;logging&lt;/code&gt; uses &lt;code&gt;%&lt;/code&gt;-style formatting, so the interpolation failed and it dumped the raw message + args block. I fixed the middle one, re-ran — and it &lt;em&gt;still&lt;/em&gt; broke, because I'd missed the one &lt;em&gt;inside the brackets&lt;/em&gt;: &lt;code&gt;[$s]&lt;/code&gt;. On a US keyboard &lt;code&gt;%&lt;/code&gt; is Shift+5 and &lt;code&gt;$&lt;/code&gt; is Shift+4, adjacent keys. I hit the wrong one, in the same line, twice.&lt;/p&gt;

&lt;p&gt;The lesson isn't "type carefully." It's that the whole &lt;em&gt;point&lt;/em&gt; of the handler — a readable server-side log — was silently defeated by one wrong character, and the only reason I caught it was reading the actual log output instead of assuming it worked. Same energy as Phase 7b's silent &lt;code&gt;except&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha 2 — a test that never ran
&lt;/h3&gt;

&lt;p&gt;I added &lt;code&gt;test_cross_user_isolation&lt;/code&gt;, ran pytest, saw green, and almost celebrated. Then I read the first line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;collected 3 items
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three. I'd written a fourth. It wasn't failing — it &lt;em&gt;didn't exist&lt;/em&gt; as far as pytest was concerned. The function had gotten indented so it was nested inside another, which makes it invisible to collection (pytest only finds top-level &lt;code&gt;test_*&lt;/code&gt; functions). No error, no failure, just... absent.&lt;/p&gt;

&lt;p&gt;Now &lt;code&gt;collected N items&lt;/code&gt; is the &lt;em&gt;first&lt;/em&gt; thing I read, before the pass/fail. A green suite that silently skips your most important test is worse than a red one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha 3 — the login that failed on one capital letter
&lt;/h3&gt;

&lt;p&gt;Coming back after a few days, my saved token was stale (JWTs expire; shell variables die with the terminal). Fine — re-authenticate. Except the login kept returning a &lt;code&gt;KeyError: 'access_token'&lt;/code&gt;. I tried &lt;code&gt;passwordd&lt;/code&gt;. Failed. I &lt;em&gt;knew&lt;/em&gt; the password was &lt;code&gt;Passwordd&lt;/code&gt; with a capital P — tried that. Still failed.&lt;/p&gt;

&lt;p&gt;The fix, as always: stop guessing, strip the pipeline, read the raw response. The real problem was the &lt;em&gt;email&lt;/em&gt; — I'd been typing &lt;code&gt;frontend1@test.com&lt;/code&gt; when the account was &lt;code&gt;Frontend1@test.com&lt;/code&gt;. Capital F.&lt;/p&gt;

&lt;p&gt;The design lesson underneath: my login returns the same generic &lt;code&gt;401&lt;/code&gt; for a bad email &lt;em&gt;or&lt;/em&gt; a bad password — which is correct (never tell an attacker which field was wrong) — but it means the error can't tell you which one you fat-fingered. That's exactly why "read the raw response" beats "stare at the error message."&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha 4 — the terminal's invisible passengers
&lt;/h3&gt;

&lt;p&gt;One curl command died with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash: &lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\3&lt;/span&gt;&lt;span class="s1"&gt;02&lt;/span&gt;&lt;span class="se"&gt;\2&lt;/span&gt;&lt;span class="s1"&gt;26&lt;/span&gt;&lt;span class="se"&gt;\3&lt;/span&gt;&lt;span class="s1"&gt;02&lt;/span&gt;&lt;span class="se"&gt;\2&lt;/span&gt;&lt;span class="s1"&gt;26curl'&lt;/span&gt;: &lt;span class="nb"&gt;command &lt;/span&gt;not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;\302\226&lt;/code&gt; is the UTF-8 for an invisible control character. Two of them had ridden along when I pasted from a rich-text source, so bash tried to run a command literally named &lt;code&gt;‖‖curl&lt;/code&gt;. The command was perfect; the paste smuggled in garbage bytes. Same family as Phase 7b's hidden comma — you can't debug what you can't see, so &lt;code&gt;cat -v&lt;/code&gt; (which prints control characters) and re-typing by hand are the escape hatches.&lt;/p&gt;

&lt;p&gt;The through-line across all four: before I'd fixed a single line of &lt;em&gt;application&lt;/em&gt; code this phase, my keyboard, my clipboard, my shell, and pytest's collector had each quietly done something other than what I intended. The bug is rarely where you first look — and it's often not even in your code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thinking like an attacker
&lt;/h2&gt;

&lt;p&gt;Same habit as always — try to break it before reality does:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attack&lt;/th&gt;
&lt;th&gt;Defense&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;POST a negative amount&lt;/td&gt;
&lt;td&gt;Pydantic &lt;code&gt;gt=0&lt;/code&gt; → 422, dies at the schema, never touches the DB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Present a garbage/expired token&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;get_current_user&lt;/code&gt; → clean 401 with &lt;code&gt;WWW-Authenticate&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request another user's expense id&lt;/td&gt;
&lt;td&gt;Owner-scoped query → 404 (not 403 — don't confirm it exists)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trigger an unhandled 500&lt;/td&gt;
&lt;td&gt;Global handler → generic body + correlation id, full trace stays server-side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request &lt;code&gt;limit=999999&lt;/code&gt; to dump everything&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Query(le=100)&lt;/code&gt; → 422&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read a secret out of "deleted" git history&lt;/td&gt;
&lt;td&gt;Swept by filename &lt;em&gt;and&lt;/em&gt; value across all branches → clean&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Learning shortcut vs. production
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;I did (learning)&lt;/th&gt;
&lt;th&gt;Production would&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Manual curl audit, then automated it&lt;/td&gt;
&lt;td&gt;Audit runs in CI on every push, blocks merge on a regression&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 focused tests&lt;/td&gt;
&lt;td&gt;Broader coverage: every endpoint, edge cases, a real coverage target&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In-memory &lt;code&gt;StaticPool&lt;/code&gt; test DB&lt;/td&gt;
&lt;td&gt;Same pattern scales fine — just more fixtures and factories&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;print&lt;/code&gt;/&lt;code&gt;logger&lt;/code&gt; to stdout&lt;/td&gt;
&lt;td&gt;Structured logging with levels + an alert on sustained 500s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deferred prod CORS to Phase 9&lt;/td&gt;
&lt;td&gt;Env-driven allowed origins, locked to the real frontend URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Called the secret history "clean"&lt;/td&gt;
&lt;td&gt;Rotate secrets anyway before a public deploy — rotation beats archaeology&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ignored the &lt;code&gt;httpx&lt;/code&gt;/&lt;code&gt;httpx2&lt;/code&gt; deprecation warning&lt;/td&gt;
&lt;td&gt;Resolve it before it becomes a hard break on a dependency bump&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these are wrong for where I am. They're &lt;em&gt;known&lt;/em&gt; — written down, not pretended away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key habits to keep
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read the real output; don't trust that it worked.&lt;/strong&gt; The &lt;code&gt;$s&lt;/code&gt; log, the uncollected test, the capital-F login — every one was caught by &lt;em&gt;observing&lt;/em&gt;, not assuming.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prove the tooling before you trust the result.&lt;/strong&gt; A smoke test against &lt;code&gt;/health&lt;/code&gt; first means any later red is a real failure, not a setup problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;collected N items&lt;/code&gt; is the first line you read.&lt;/strong&gt; A green suite that silently skips a test is worse than a red one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip the pipeline, read the raw response.&lt;/strong&gt; A &lt;code&gt;KeyError&lt;/code&gt; in a wrapper hides the &lt;code&gt;{"detail": ...}&lt;/code&gt; that would've told you the answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixtures must clean up.&lt;/strong&gt; A global &lt;code&gt;dependency_override&lt;/code&gt; poisons every other test. &lt;code&gt;yield&lt;/code&gt; + teardown keeps them isolated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;200&lt;/code&gt; control prevents a false-pass &lt;code&gt;404&lt;/code&gt;.&lt;/strong&gt; Prove the thing exists &lt;em&gt;and&lt;/em&gt; that the wrong user can't see it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Git history is permanent, and secrets leak by content, not filename.&lt;/strong&gt; Sweep for the value across all branches, not just the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A filter matches strings, not meaning.&lt;/strong&gt; &lt;code&gt;alembic/env.py&lt;/code&gt; was innocent — always eyeball what matched.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up: Phase 9
&lt;/h2&gt;

&lt;p&gt;The app is trustworthy now. The audit found no leaks, the unexpected 500 has a net, five tests lock in every guarantee that used to live only in my memory, and the git history is clean.&lt;/p&gt;

&lt;p&gt;What's left is the last mile: getting it &lt;em&gt;onto the internet&lt;/em&gt;. Phase 9 is deployment — starting with the one item I consciously deferred (production CORS, once I know the real frontend URL), then Render itself, where my own lessons come home to roost: Linux means &lt;code&gt;venv/bin/activate&lt;/code&gt;, not &lt;code&gt;Scripts/&lt;/code&gt;, and paths are case-sensitive (the capital-F login bug, now at the OS level). Secrets become real environment variables in a dashboard, not a &lt;code&gt;.env&lt;/code&gt; file. And I'll confirm debug is off, closing the loop on the exception handler.&lt;/p&gt;

&lt;p&gt;The features were the fun part. Turns out making them &lt;em&gt;trustworthy&lt;/em&gt; was the part that actually taught me something.&lt;/p&gt;

&lt;p&gt;See you in the next one.&lt;/p&gt;

&lt;p&gt;silentcarry&lt;/p&gt;

</description>
      <category>backend</category>
      <category>python</category>
      <category>security</category>
      <category>testing</category>
    </item>
    <item>
      <title>Phase 7b — Bolting On the AI: An LLMCategorizer Behind the Same Door (with Caching, a Fallback, and Six Model Names)</title>
      <dc:creator>Azhar Alvi </dc:creator>
      <pubDate>Sun, 09 Aug 2026 16:30:19 +0000</pubDate>
      <link>https://dev.to/silentcarry/phase-7b-bolting-on-the-ai-an-llmcategorizer-behind-the-same-door-with-caching-a-fallback-and-45n6</link>
      <guid>https://dev.to/silentcarry/phase-7b-bolting-on-the-ai-an-llmcategorizer-behind-the-same-door-with-caching-a-fallback-and-45n6</guid>
      <description>&lt;p&gt;The seam I built in Phase 7a finally earned its keep.&lt;/p&gt;

&lt;p&gt;I dropped a Gemini-backed categorizer in behind the &lt;em&gt;exact&lt;/em&gt; same interface — &lt;code&gt;main.py&lt;/code&gt; never noticed — with a config toggle, description-level caching so I'm not paying for the same "Starbucks" twice, and a rules fallback for when the model is down, slow, or wrong.&lt;/p&gt;

&lt;p&gt;Here's the build, the six model names it took to get one working call, and the silent &lt;code&gt;except&lt;/code&gt; block that hid a completely broken LLM for an embarrassingly long time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Index
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Where we left off&lt;/li&gt;
&lt;li&gt;The plan: everything behind the same contract&lt;/li&gt;
&lt;li&gt;Step 1 — The toggle: a factory and an env var&lt;/li&gt;
&lt;li&gt;Step 2 — The stub: prove the wiring before the call&lt;/li&gt;
&lt;li&gt;Step 3 — The real call: constrained prompt, validation, fallback&lt;/li&gt;
&lt;li&gt;Step 4 — Caching: don't pay for the same Starbucks twice&lt;/li&gt;
&lt;li&gt;One loose end: the CSV finally learned about categories&lt;/li&gt;
&lt;li&gt;The war story: six model names and a comma&lt;/li&gt;
&lt;li&gt;Thinking like an attacker&lt;/li&gt;
&lt;li&gt;Learning shortcut vs. production&lt;/li&gt;
&lt;li&gt;Key habits to keep&lt;/li&gt;
&lt;li&gt;Next up: Phase 8&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where we left off
&lt;/h2&gt;

&lt;p&gt;Phase 7a ended with a promise. I had a nullable &lt;code&gt;category&lt;/code&gt; column, a dumb-but-honest rules engine, and — the part I actually cared about — a &lt;code&gt;Categorizer&lt;/code&gt; &lt;strong&gt;Protocol&lt;/strong&gt; with the rules implementation hiding behind it. I closed that post like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Now the fun part: an &lt;code&gt;LLMCategorizer&lt;/code&gt; that satisfies the exact same &lt;code&gt;Categorizer&lt;/code&gt; contract — so &lt;code&gt;main.py&lt;/code&gt; never knows the difference — with a config toggle, per-merchant caching, and a rules fallback for when the model is slow, down, or just wrong. Phase 7b is where I find out if I actually earned it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is that. Spoiler: the seam held. The &lt;em&gt;account quota&lt;/em&gt; is a different story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plan: everything behind the same contract
&lt;/h2&gt;

&lt;p&gt;The temptation, again, was to reach straight for the API call and stuff it into &lt;code&gt;categorize()&lt;/code&gt;. I didn't. I built outward from the safe end, one behavior-neutral step at a time:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Why this order&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;A &lt;code&gt;CATEGORIZER&lt;/code&gt; toggle + factory&lt;/td&gt;
&lt;td&gt;One place decides rules-vs-LLM, defaults to rules — the app behaves identically until I flip it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;An &lt;code&gt;LLMCategorizer&lt;/code&gt; stub that returns &lt;code&gt;None&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Prove the toggle + wiring end-to-end with zero risk and zero cost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;The real Gemini call, with validation + fallback&lt;/td&gt;
&lt;td&gt;The risky external dependency goes in &lt;em&gt;last&lt;/em&gt;, behind the seam&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Description-level caching&lt;/td&gt;
&lt;td&gt;Stop paying for the same description twice&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The principle from 7a still drives it: &lt;strong&gt;build the boundary, then plug in the flaky thing.&lt;/strong&gt; Every step until Step 3 leaves the app doing exactly what it did before. That's not caution for its own sake — it means when something finally breaks, I know it broke in the &lt;em&gt;one&lt;/em&gt; thing I just changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — The toggle: a factory and an env var
&lt;/h2&gt;

&lt;p&gt;Right now the last line of &lt;code&gt;categorization.py&lt;/code&gt; hard-codes the choice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;default_categorizer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Categorizer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RulesCategorizer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's fine for one implementation. But if I later scatter &lt;code&gt;if CATEGORIZER == "llm"&lt;/code&gt; checks across &lt;code&gt;main.py&lt;/code&gt;, I've made a mess. So I gave the decision a single owner — a &lt;strong&gt;factory&lt;/strong&gt;: a function whose only job is to construct and hand back the right object based on config.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dotenv&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;load_dotenv&lt;/span&gt;

&lt;span class="nf"&gt;load_dotenv&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# read .env before we read any env vars
&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_categorizer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Categorizer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Pick the categorizer based on the CATEGORIZER env var. Defaults to rules.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CATEGORIZER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rules&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;LLMCategorizer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;RulesCategorizer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="n"&gt;default_categorizer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Categorizer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_categorizer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two ideas clicked here:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Idea&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;os.getenv("X", "rules")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;This is an &lt;em&gt;optional&lt;/em&gt; setting with a safe default — same shape as my &lt;code&gt;DATABASE_URL&lt;/code&gt; read. Contrast with &lt;code&gt;os.environ["X"]&lt;/code&gt;, which I use for required secrets so the app fails loud if they're missing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keep the name &lt;code&gt;default_categorizer&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;main.py&lt;/code&gt; still does &lt;code&gt;from categorization import default_categorizer&lt;/code&gt;. Rebinding it to &lt;code&gt;get_categorizer()&lt;/code&gt;'s result means &lt;strong&gt;not one line of &lt;code&gt;main.py&lt;/code&gt; changes&lt;/strong&gt; — the call site stays blissfully ignorant&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Env unset → &lt;code&gt;"rules"&lt;/code&gt; → &lt;code&gt;RulesCategorizer&lt;/code&gt;. The app behaves exactly like Phase 7a. Behavior-neutral seam, done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — The stub: prove the wiring before the call
&lt;/h2&gt;

&lt;p&gt;Before I wrote a single line of Gemini code, I added a &lt;strong&gt;stub&lt;/strong&gt; — a class with the right &lt;em&gt;shape&lt;/em&gt; (it satisfies the &lt;code&gt;Categorizer&lt;/code&gt; Protocol) but no real logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LLMCategorizer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;LLM-backed categorizer. STUB for now: returns None until the real call is wired in.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;categorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why bother stubbing when I could just write the real thing? &lt;strong&gt;Isolation.&lt;/strong&gt; If I drop a live API call straight in and something misbehaves, I can't tell whether it's the &lt;em&gt;wiring&lt;/em&gt; (env toggle → factory → app) or the &lt;em&gt;call itself&lt;/em&gt;. The stub lets me flip &lt;code&gt;CATEGORIZER=llm&lt;/code&gt;, restart, add a "swiggy" expense, and confirm it comes back &lt;strong&gt;Uncategorized&lt;/strong&gt; — which proves the &lt;code&gt;llm&lt;/code&gt; path is live and reaching my class. Zero cost, zero network.&lt;/p&gt;

&lt;p&gt;And returning &lt;code&gt;None&lt;/code&gt; is the &lt;em&gt;correct&lt;/em&gt; stub behavior, not a placeholder shrug: my whole design treats &lt;code&gt;None&lt;/code&gt; as "no category → NULL → Uncategorized." So a stub that categorizes nothing degrades to the exact same UX as the rules engine finding no match. Nothing breaks; the app just stops guessing while the stub is in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — The real call: constrained prompt, validation, fallback
&lt;/h2&gt;

&lt;p&gt;This is the step the whole phase was building toward. Three non-negotiables went in together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A constrained prompt.&lt;/strong&gt; I don't let the model free-associate. It picks &lt;em&gt;exactly one&lt;/em&gt; of my five existing categories, or the literal word &lt;code&gt;None&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LLMCategorizer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;LLM-backed categorizer using Google Gemini, constrained to the rules categories.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;api_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GEMINI_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# fail loud if llm is on but no key
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;genai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CATEGORY_RULES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_fallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RulesCategorizer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;categorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;categories&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_allowed&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You are an expense categorizer. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Choose exactly ONE category for the expense from this list: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Reply with only the category name and nothing else. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;If it does not clearly fit any category, reply with the single word: None.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Expense description: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate_content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;GEMINI_MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;contents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_allowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[categorizer] LLM ERROR -&amp;gt; falling back to rules: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_fallback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;categorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why constrain it to five categories instead of letting it invent its own? Because free-form output is data poison. Left alone, an LLM will happily return "Dining," "Restaurants," "Food &amp;amp; Drink," and "Meals" for four expenses that are all &lt;em&gt;Food&lt;/em&gt; — and my future "spend by category" report splinters into meaningless buckets. A controlled vocabulary is what makes category-based reporting possible, and it keeps the LLM apples-to-apples comparable with the rules engine. Same five-word language, clean swap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Never trust the output.&lt;/strong&gt; LLMs drift: &lt;code&gt;"Food."&lt;/code&gt;, &lt;code&gt;" food"&lt;/code&gt;, &lt;code&gt;"**Food**"&lt;/code&gt;. So I normalize (strip whitespace and a trailing period, compare case-insensitively) and only accept an answer that &lt;em&gt;exactly&lt;/em&gt; matches one of my five. Anything else becomes &lt;code&gt;None&lt;/code&gt; — the model declining to guess, which is the behavior I want.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Wrap the call and fall back to rules.&lt;/strong&gt; Any failure — no network, quota exceeded, timeout, garbage response — must &lt;strong&gt;not&lt;/strong&gt; break expense creation. On any exception I quietly return the rules answer.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concept&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Client built in &lt;code&gt;__init__&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Auth + connection set up &lt;em&gt;once&lt;/em&gt; per instance, reused for every call — the factory returns a single &lt;code&gt;LLMCategorizer()&lt;/code&gt;, so it's built once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;os.environ["GEMINI_API_KEY"]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fail loud: if &lt;code&gt;llm&lt;/code&gt; is on but the key's missing, refuse to start. Only read when the factory actually builds the LLM path, so &lt;code&gt;rules&lt;/code&gt; mode never needs it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;self._allowed = set(CATEGORY_RULES.keys())&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One source of truth — add a sixth rules category and the LLM's allowed list updates for free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;except Exception&lt;/code&gt; → rules&lt;/td&gt;
&lt;td&gt;Normally a code smell. Here it's &lt;em&gt;deliberate&lt;/em&gt;: categorization is a non-critical enhancement that must never block the core POST&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A bare &lt;code&gt;except Exception&lt;/code&gt; catching &lt;em&gt;everything&lt;/em&gt; is usually lazy. This one is a conscious trade-off — I'd rather an LLM outage silently degrade to rules than ever take down expense creation. But "silent" turned out to be the word that bit me. More on that in the war story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Caching: don't pay for the same Starbucks twice
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;categorize()&lt;/code&gt; call hits Gemini, even for a description I've seen ten times. On a free tier, that's wasted latency and wasted quota. The fix is &lt;strong&gt;memoization&lt;/strong&gt; — remember what an input mapped to, and on a repeat, return the stored answer with no API call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;categorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="c1"&gt;# ... build prompt, call Gemini ...
&lt;/span&gt;    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# ... get validated result ...
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;   &lt;span class="c1"&gt;# cache ONLY successful LLM outcomes
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[categorizer] LLM ERROR -&amp;gt; falling back to rules: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_fallback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;categorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# never cached
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concept&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A dict as cache&lt;/td&gt;
&lt;td&gt;Key = normalized description, value = resolved category&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;key = description.strip().lower()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"Uber"&lt;/code&gt;, &lt;code&gt;"uber "&lt;/code&gt;, &lt;code&gt;"UBER"&lt;/code&gt; all hit the &lt;em&gt;same&lt;/em&gt; entry — trivial differences shouldn't cost a call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In-memory&lt;/td&gt;
&lt;td&gt;Lives on the instance, clears on restart. Acceptable for now; a DB/Redis cache is a later step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache &lt;strong&gt;only&lt;/strong&gt; on success&lt;/td&gt;
&lt;td&gt;The subtle one — see below&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is the one worth internalizing. The cache write lives &lt;em&gt;inside the &lt;code&gt;try&lt;/code&gt;, on the success path only&lt;/em&gt;. If the API errors and I fall back to rules, I must &lt;strong&gt;not&lt;/strong&gt; store that. Picture Gemini down for a minute: I'd cache the fallback answer for "Starbucks" and keep serving it even after Gemini recovers. A poisoned cache outlives the outage. So fallbacks are never cached — ever.&lt;/p&gt;

&lt;p&gt;The proof it worked, straight from the logs — I typed &lt;code&gt;kFc&lt;/code&gt;, watched it call Gemini once, then added &lt;code&gt;kFc&lt;/code&gt; again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;categorizer&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'kfc'&lt;/span&gt; &lt;span class="k"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'kfc'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;categorizer&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;CACHE&lt;/span&gt; &lt;span class="n"&gt;HIT&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;expenses&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'kFc'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Food'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2026-08-09'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things confirmed in four lines: the LLM genuinely ran (&lt;code&gt;KFC&lt;/code&gt; isn't a rules keyword, yet it returned Food), normalization works (&lt;code&gt;kFc&lt;/code&gt; → &lt;code&gt;'kfc'&lt;/code&gt;), and the repeat was served from cache with &lt;em&gt;zero&lt;/em&gt; API calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  One loose end: the CSV finally learned about categories
&lt;/h2&gt;

&lt;p&gt;Small but real: my Phase 6 CSV export still wrote &lt;code&gt;["id", "description", "amount", "spent_on"]&lt;/code&gt; — it never knew the &lt;code&gt;category&lt;/code&gt; column existed. A CSV export has two places that must agree: the &lt;strong&gt;header row&lt;/strong&gt; and the &lt;strong&gt;data rows&lt;/strong&gt;, and they're positional. Add a column to one, add it at the &lt;em&gt;same index&lt;/em&gt; in the other, or every cell below shifts and the file is silently wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writerow&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spent_on&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="c1"&gt;# ...
&lt;/span&gt;&lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writerow&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spent_on&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;None&lt;/code&gt; category writes as a blank cell, which is the honest representation of "uncategorized." Now the category flows all the way from the model to the download.&lt;/p&gt;

&lt;h2&gt;
  
  
  The war story: six model names and a comma
&lt;/h2&gt;

&lt;p&gt;Phase 7a's bugs were all about the four flavors of "nothing" — &lt;code&gt;null&lt;/code&gt; vs &lt;code&gt;""&lt;/code&gt; vs omitted vs UTC-today. Phase 7b had its own theme: &lt;strong&gt;everything that isn't your code lying to you&lt;/strong&gt; — config, the reload watcher, the environment, and a swallowed exception all conspired before I'd made a single real API call successfully.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha 1 — the comma that lived in my &lt;code&gt;.env&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;I flipped the toggle on, restarted, added a "swiggy" expense... and it still came back Food. Was &lt;code&gt;.env&lt;/code&gt; not being read? I stopped guessing and printed the value with &lt;code&gt;!r&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[categorizer] CATEGORIZER=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;CATEGORIZER&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# [categorizer] CATEGORIZER='llm, '
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it is: &lt;code&gt;'llm, '&lt;/code&gt; — a trailing comma and a space, baked into the value. In a &lt;code&gt;.env&lt;/code&gt; file, &lt;em&gt;everything&lt;/em&gt; after the &lt;code&gt;=&lt;/code&gt; is literal, so my stray punctuation became part of the string. &lt;code&gt;"llm, " == "llm"&lt;/code&gt; is &lt;code&gt;False&lt;/code&gt;, the factory fell through to rules, and I got Food.&lt;/p&gt;

&lt;p&gt;The lesson isn't "don't type commas." It's &lt;strong&gt;&lt;code&gt;!r&lt;/code&gt; earns its keep.&lt;/strong&gt; Without &lt;code&gt;repr()&lt;/code&gt;, the log would've read &lt;code&gt;CATEGORIZER=llm&lt;/code&gt; and I'd have sworn it was correct — the quotes are the only reason I saw the hidden &lt;code&gt;,&lt;/code&gt;. I later hardened the read with &lt;code&gt;.strip().lower()&lt;/code&gt; so a stray space can't do this again (a comma still would — env values are exact).&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha 2 — &lt;code&gt;--reload&lt;/code&gt; doesn't watch &lt;code&gt;.env&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Before I found the comma, I'd been editing &lt;code&gt;.env&lt;/code&gt; and waiting for uvicorn to pick it up. It never did. &lt;code&gt;--reload&lt;/code&gt; only re-imports when a &lt;strong&gt;&lt;code&gt;.py&lt;/code&gt;&lt;/strong&gt; file changes. &lt;code&gt;default_categorizer&lt;/code&gt; is bound &lt;em&gt;once&lt;/em&gt;, at import, when &lt;code&gt;get_categorizer()&lt;/code&gt; runs — so editing &lt;code&gt;.env&lt;/code&gt; and saving does nothing until a true cold restart (&lt;code&gt;Ctrl+C&lt;/code&gt;, then relaunch). The running process was holding the categorizer it built at its last real startup. Now "changed &lt;code&gt;.env&lt;/code&gt;?" always means "cold restart," not "save and hope."&lt;/p&gt;

&lt;p&gt;(VS Code chimed in here too, offering to load &lt;code&gt;.env&lt;/code&gt; into the terminal via &lt;code&gt;python.terminal.useEnvFile&lt;/code&gt;. Irrelevant — my code calls &lt;code&gt;load_dotenv()&lt;/code&gt; and reads the file itself at runtime. Enabling it wouldn't have fixed anything. Dismissed.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha 3 — "No module named 'google'" — the environment, not the folder
&lt;/h3&gt;

&lt;p&gt;Installed &lt;code&gt;google-genai&lt;/code&gt;, restarted, and: &lt;code&gt;No module named 'google'&lt;/code&gt;. My first instinct was "wrong folder." Wrong instinct. &lt;code&gt;pip install&lt;/code&gt; doesn't install into whatever directory you're standing in — it installs into whichever &lt;strong&gt;virtualenv is activated&lt;/strong&gt;, landing in that venv's &lt;code&gt;site-packages&lt;/code&gt;. The error meant the Python running uvicorn wasn't the same environment where the package landed. A reinstall in a properly-activated terminal fixed it instantly.&lt;/p&gt;

&lt;p&gt;The mental model I locked in: a venv is a sealed box of packages; activating it points &lt;em&gt;this terminal's&lt;/em&gt; &lt;code&gt;pip&lt;/code&gt;/&lt;code&gt;python&lt;/code&gt; at that box. &lt;code&gt;No module named X&lt;/code&gt; = box mismatch. The one-line diagnostic is &lt;code&gt;pip show &amp;lt;pkg&amp;gt;&lt;/code&gt; run &lt;strong&gt;in the terminal that runs the server&lt;/strong&gt; — if it's not found there, the boxes don't match. (And a running process won't see a newly installed package until you restart it — same cold-restart discipline as Gotcha 2.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha 4 — the silent &lt;code&gt;except&lt;/code&gt; that hid a dead LLM
&lt;/h3&gt;

&lt;p&gt;This is the big one, and it's the reason I now distrust quiet error handling. My fallback was working &lt;em&gt;too&lt;/em&gt; well. Every test word I'd been using — &lt;code&gt;uber&lt;/code&gt;, &lt;code&gt;netflix&lt;/code&gt;, &lt;code&gt;lunch&lt;/code&gt; — is &lt;em&gt;also&lt;/em&gt; a rules keyword. So when the Gemini call failed and hit &lt;code&gt;except → rules&lt;/code&gt;, I still got the "right" category and had no idea the LLM had never actually run. The safety net was masking a completely broken call.&lt;/p&gt;

&lt;p&gt;The fix was to make the &lt;code&gt;except&lt;/code&gt; &lt;em&gt;talk&lt;/em&gt; instead of swallow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[categorizer] LLM ERROR -&amp;gt; falling back to rules: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_fallback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;categorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the way to prove the LLM was genuinely working was to test with a word the rules engine &lt;em&gt;doesn't&lt;/em&gt; know — &lt;code&gt;Starbucks&lt;/code&gt;, &lt;code&gt;KFC&lt;/code&gt;. If those come back categorized, only the model could have done it. Rules would've said Uncategorized.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha 5 — six model names
&lt;/h3&gt;

&lt;p&gt;Once the &lt;code&gt;except&lt;/code&gt; could speak, it immediately told me why nothing worked — a parade of failures across model names:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model I tried&lt;/th&gt;
&lt;th&gt;What the API said&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemini-2.5-flash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;404&lt;/code&gt; — "no longer available to new users"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemini-2.5-flash-lite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;404&lt;/code&gt; — same gating for new keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemini-2.0-flash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;429 RESOURCE_EXHAUSTED&lt;/code&gt;, &lt;code&gt;limit: 0&lt;/code&gt; — free tier is &lt;em&gt;zero&lt;/em&gt; on this model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemini-flash-latest&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Finally — a clean &lt;code&gt;generate_content&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;429&lt;/code&gt; was the sneaky one: &lt;code&gt;limit: 0&lt;/code&gt; doesn't mean "you used it up," it means "your key was never granted free-tier allowance for this model." That's an account/billing fact, not a code bug. The move that ended the guessing was to stop &lt;em&gt;assuming&lt;/em&gt; model names and &lt;strong&gt;ask the key what it can do&lt;/strong&gt; — &lt;code&gt;client.models.list()&lt;/code&gt; prints every model available to you. Discover capabilities; don't hard-code hope.&lt;/p&gt;

&lt;p&gt;I settled on the &lt;code&gt;gemini-flash-latest&lt;/code&gt; alias. It's a moving target (it'll drift to newer models over time, which is bad for reproducibility), but a working alias beats a pinned name that &lt;code&gt;404&lt;/code&gt;s. Pinning a specific stable model is a known, deferred trade-off.&lt;/p&gt;

&lt;p&gt;The through-line: before I'd made &lt;em&gt;one&lt;/em&gt; successful API call, four different non-code layers had already lied to me — config, the reload watcher, the environment, and my own swallowed exception. &lt;strong&gt;The bug is rarely where you first look, and a silent &lt;code&gt;except&lt;/code&gt; guarantees you'll look everywhere else first.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Thinking like an attacker
&lt;/h2&gt;

&lt;p&gt;Same habit as always — actively try to break my own feature before reality does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LLM returns &lt;code&gt;"Food."&lt;/code&gt; with a period?&lt;/strong&gt; &lt;code&gt;.strip(".")&lt;/code&gt; + exact-match validation → still maps to Food.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LLM returns something off-menu like &lt;code&gt;"Groceries"&lt;/code&gt;?&lt;/strong&gt; Not in my five → &lt;code&gt;None&lt;/code&gt; → Uncategorized. It can't invent a category and poison my data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API is down mid-request?&lt;/strong&gt; &lt;code&gt;except&lt;/code&gt; → rules fallback → the expense still saves. Proven live when the &lt;code&gt;429&lt;/code&gt; hit: the row saved as Uncategorized, the app didn't crash, the log named the reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same "Starbucks" typed ten times?&lt;/strong&gt; One API call, nine cache hits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemini flaps during an outage?&lt;/strong&gt; Fallback answers are never cached, so a recovered model isn't shadowed by a stale rules guess.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Learning shortcut vs. production
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;I did (learning)&lt;/th&gt;
&lt;th&gt;Production would&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;In-memory dict cache&lt;/td&gt;
&lt;td&gt;Persistent cache (DB/Redis) that survives restarts, with TTL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;gemini-flash-latest&lt;/code&gt; alias&lt;/td&gt;
&lt;td&gt;A pinned model version for reproducible behavior + cost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free-tier key, &lt;code&gt;limit: 0&lt;/code&gt; and all&lt;/td&gt;
&lt;td&gt;Billing enabled (flash-lite is fractions of a cent) or a managed quota&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;except Exception&lt;/code&gt; catch-all&lt;/td&gt;
&lt;td&gt;Catch specific API errors; alert on sustained fallback, not just log it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;print(...)&lt;/code&gt; for the error&lt;/td&gt;
&lt;td&gt;Structured logging with levels and a metric on fallback rate&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these are wrong for where I am. They're &lt;em&gt;known&lt;/em&gt; — written down, not pretended away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key habits to keep
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build the seam before the risky dependency.&lt;/strong&gt; The &lt;code&gt;Categorizer&lt;/code&gt; Protocol meant the LLM was a drop-in — &lt;code&gt;main.py&lt;/code&gt; never changed. Phase 7a earned this; Phase 7b spent it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stub before you call.&lt;/strong&gt; Prove the wiring with a &lt;code&gt;None&lt;/code&gt;-returning placeholder before you introduce the network, the auth, and the quota all at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never let &lt;code&gt;except&lt;/code&gt; be silent.&lt;/strong&gt; A swallowed error and a convenient fallback will hide a totally broken feature. Make it log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test with inputs your fallback can't fake.&lt;/strong&gt; If rules and LLM would both say "Food," you've proven nothing. Use a word only the LLM could know.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask the system what it can do.&lt;/strong&gt; &lt;code&gt;models.list()&lt;/code&gt; beat four rounds of guessing model names.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;!r&lt;/code&gt; when you print an env value.&lt;/strong&gt; The quotes are how you catch the hidden comma.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache success, never failure.&lt;/strong&gt; A poisoned cache outlives the outage that caused it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up: Phase 8
&lt;/h2&gt;

&lt;p&gt;The column holds a category. Rules fill it offline. The LLM fills it when it's on — behind the same door, validated, cached, and with a fallback that's now &lt;em&gt;loud&lt;/em&gt; about failing. The human can still override all of it.&lt;/p&gt;

&lt;p&gt;What's nagging me: the cache dies on every restart, the model name is a drifting alias, and my free tier can't actually afford to be the default. So Phase 8 is probably where I make the LLM path &lt;em&gt;sustainable&lt;/em&gt; — a persistent cache, a pinned model, and a real answer to the quota question — or where I finally let categories drive the thing I've been building toward all along: reporting that actually means something.&lt;/p&gt;

&lt;p&gt;The seam held. Turns out the hard part was never the code behind it.&lt;/p&gt;

&lt;p&gt;See you in the next one.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;— silentcarry&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>llm</category>
      <category>python</category>
    </item>
    <item>
      <title>Phase 7a — Getting Opinionated: Rules-Based Auto-Categorization (and a Seam for the AI Later)</title>
      <dc:creator>Azhar Alvi </dc:creator>
      <pubDate>Sun, 09 Aug 2026 06:32:37 +0000</pubDate>
      <link>https://dev.to/silentcarry/phase-7a-getting-opinionated-rules-based-auto-categorization-and-a-seam-for-the-ai-later-2cp5</link>
      <guid>https://dev.to/silentcarry/phase-7a-getting-opinionated-rules-based-auto-categorization-and-a-seam-for-the-ai-later-2cp5</guid>
      <description>&lt;p&gt;&lt;em&gt;My expense app finally has a point of view on what I'm spending money on. No AI yet — just honest keyword rules, a nullable column, and one interface that means I can bolt an LLM on later without ripping anything out. Here's the build, three "empty value" bugs that bit me, and the habits that kept it clean.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Index
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Where we left off&lt;/li&gt;
&lt;li&gt;The plan: rules first, AI behind the same door&lt;/li&gt;
&lt;li&gt;Step 1 — A nullable column (and why nullable matters)&lt;/li&gt;
&lt;li&gt;Step 2 — The migration: generate → review → apply&lt;/li&gt;
&lt;li&gt;Step 3 — A dumb-but-working &lt;code&gt;categorize()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Step 4 — Wiring it into create (with override precedence)&lt;/li&gt;
&lt;li&gt;Step 5 — The seam: extracting behind a &lt;code&gt;Categorizer&lt;/code&gt; interface&lt;/li&gt;
&lt;li&gt;Step 6 — The UI loop: show, add, edit&lt;/li&gt;
&lt;li&gt;🐛 The war story: three ways "empty" lied to me&lt;/li&gt;
&lt;li&gt;Thinking like an attacker&lt;/li&gt;
&lt;li&gt;Learning shortcut vs. production&lt;/li&gt;
&lt;li&gt;Key habits to keep&lt;/li&gt;
&lt;li&gt;Next up: Phase 7b&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;a&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where we left off
&lt;/h2&gt;

&lt;p&gt;Phase 6 gave me the receipts — date-range reports and CSV export. I ended that post with a promise:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Next up: Phase 7, where categories finally enter the schema and the app starts to get opinionated about what I'm spending on.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is that. But it turned into a bigger beast than one post, so I'm splitting it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Phase 7a (this post):&lt;/strong&gt; the schema, a rules-based categorizer, the interface seam, and the full UI loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 7b (next):&lt;/strong&gt; the actual LLM — an &lt;code&gt;LLMCategorizer&lt;/code&gt; that slots in behind the same interface, with caching and a rules fallback.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Doing rules first isn't a cop-out. It's the whole strategy.&lt;/p&gt;




&lt;p&gt;&lt;a&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The plan: rules first, AI behind the same door
&lt;/h2&gt;

&lt;p&gt;The temptation with "AI categorization" is to reach straight for the API key. I didn't. Here's the order I actually built in, and why:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Why this order&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Nullable &lt;code&gt;category&lt;/code&gt; column&lt;/td&gt;
&lt;td&gt;The app needs somewhere to &lt;em&gt;store&lt;/em&gt; a category before it can &lt;em&gt;fill&lt;/em&gt; one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Rules &lt;code&gt;categorize()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;A working, free, offline fallback — and a baseline to test against&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Extract behind an interface&lt;/td&gt;
&lt;td&gt;So the LLM can slot in later without touching call sites&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;UI loop (show / add / edit)&lt;/td&gt;
&lt;td&gt;Give the human final say, no matter how smart the auto-fill gets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;(Phase 7b)&lt;/em&gt; LLM implementation&lt;/td&gt;
&lt;td&gt;The risky external dependency goes in &lt;strong&gt;last&lt;/strong&gt;, behind the seam&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The principle: &lt;strong&gt;build the boundary before you plug in the flaky thing.&lt;/strong&gt; Rules are boring and reliable. The LLM will be clever and occasionally down, slow, or wrong. If both live behind the same contract, swapping between them — or falling back — is a one-line change.&lt;/p&gt;




&lt;p&gt;&lt;a&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — A nullable column (and why nullable matters)
&lt;/h2&gt;

&lt;p&gt;One new line on the &lt;code&gt;Expense&lt;/code&gt; model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting bit is the &lt;code&gt;| None&lt;/code&gt; / &lt;code&gt;nullable=True&lt;/code&gt;. Every expense already in my database was created &lt;em&gt;before&lt;/em&gt; this column existed. If I made it &lt;code&gt;nullable=False&lt;/code&gt;, the migration would try to force a value into all those existing rows and fail — or demand a default I don't actually want.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nullable is honest:&lt;/strong&gt; old rows are genuinely uncategorized. "No category" is a real state, not an error. And it maps perfectly onto the rules engine returning "I don't know" — more on that below.&lt;/p&gt;

&lt;p&gt;New syntax I picked up here:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Syntax&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Mapped[str]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A required string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;`Mapped[str \&lt;/td&gt;
&lt;td&gt;None]`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;String(50)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Length-bounded, like my &lt;code&gt;description&lt;/code&gt; at 255&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;&lt;a&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — The migration: generate → review → apply
&lt;/h2&gt;

&lt;p&gt;Three deliberate moves, not one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Generate (does NOT touch the DB — just writes a script)&lt;/span&gt;
alembic revision &lt;span class="nt"&gt;--autogenerate&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"add category column to expenses"&lt;/span&gt;

&lt;span class="c"&gt;# 2. Review — open the file, confirm it does EXACTLY one thing&lt;/span&gt;
&lt;span class="c"&gt;#    upgrade() -&amp;gt; add_column('expenses', 'category', String(50), nullable=True)&lt;/span&gt;
&lt;span class="c"&gt;#    downgrade() -&amp;gt; drop_column('expenses', 'category')   # reversible!&lt;/span&gt;

&lt;span class="c"&gt;# 3. Apply (this one DOES modify the database)&lt;/span&gt;
alembic upgrade &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Autogenerate is good, not infallible. It occasionally invents spurious type tweaks, and SQLite has its quirks with certain operations. So I read the file every single time before applying — confirming both that &lt;code&gt;upgrade()&lt;/code&gt; does only what I asked and that &lt;code&gt;downgrade()&lt;/code&gt; cleanly reverses it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backup habit:&lt;/strong&gt; SQLite is a single file, so a backup is a &lt;code&gt;cp&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp &lt;/span&gt;expenses.db expenses.backup.db
alembic upgrade &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verified the column actually landed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import sqlite3; print(sqlite3.connect('expenses.db').execute('PRAGMA table_info(expenses)').fetchall())"&lt;/span&gt;
&lt;span class="c"&gt;# ...a 'category' row, VARCHAR(50), nullable. Existing rows: None. &lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Aside: my &lt;code&gt;.gitignore&lt;/code&gt; had &lt;code&gt;*.db&lt;/code&gt; but not &lt;code&gt;*.bak&lt;/code&gt;. Named the backup &lt;code&gt;expenses.backup.db&lt;/code&gt; so the existing rule caught it, then added &lt;code&gt;*.bak&lt;/code&gt; anyway as housekeeping.)&lt;/p&gt;




&lt;p&gt;&lt;a&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — A dumb-but-working &lt;code&gt;categorize()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;No AI. Just keywords:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;CATEGORY_RULES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Food&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;swiggy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zomato&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;restaurant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cafe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;coffee&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pizza&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lunch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dinner&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;breakfast&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;meal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;food&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grocery&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;canteen&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Transport&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uber&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ola&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cab&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fuel&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;petrol&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metro&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Shopping&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amazon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;flipkart&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myntra&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mall&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Utilities&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;electricity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;water&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gas&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;internet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wifi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;recharge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Entertainment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;netflix&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spotify&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;movie&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bookmyshow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;categorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keywords&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;CATEGORY_RULES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keyword&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The concepts I actually learned writing this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concept&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.lower()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Case-insensitive matching — &lt;code&gt;"UBER"&lt;/code&gt;, &lt;code&gt;"Uber"&lt;/code&gt;, &lt;code&gt;"uber"&lt;/code&gt; all hit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;keyword in text&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Substring test — &lt;code&gt;"uber" in "uber to airport"&lt;/code&gt; is &lt;code&gt;True&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;any(... for ...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Returns &lt;code&gt;True&lt;/code&gt; on the first match, then stops&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;return None&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No match = no guess&lt;/strong&gt; — maps onto the nullable column&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last line is the design decision I'm proudest of. When the rules don't recognize something, they don't guess wildly — they return &lt;code&gt;None&lt;/code&gt;, and the expense stays honestly uncategorized. Tested in isolation before wiring anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"from main import categorize; print(categorize('Uber to airport'), '|', categorize('Swiggy dinner'), '|', categorize('Random mystery charge'))"&lt;/span&gt;
&lt;span class="c"&gt;# Transport | Food | None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;&lt;a&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Wiring it into create (with override precedence)
&lt;/h2&gt;

&lt;p&gt;The rule: &lt;strong&gt;if the user explicitly sends a category, respect it. Only auto-fill when they don't.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_dump&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;categorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;expense&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;data.get("category")&lt;/code&gt; returns &lt;code&gt;None&lt;/code&gt; if the key is missing &lt;em&gt;or&lt;/em&gt; sent as null — my "did the user leave it blank?" test. Blank → auto-categorize. Provided → keep theirs.&lt;/p&gt;

&lt;p&gt;This is also where the first landmine was waiting (see the war story). But conceptually: the client now has three honest options — omit it (auto), send a value (override), or send &lt;code&gt;null&lt;/code&gt; (explicitly uncategorized).&lt;/p&gt;




&lt;p&gt;&lt;a&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — The seam: extracting behind a &lt;code&gt;Categorizer&lt;/code&gt; interface
&lt;/h2&gt;

&lt;p&gt;This is the step that makes Phase 7b painless. Right now &lt;code&gt;categorize()&lt;/code&gt; lives inside a web route — core domain logic welded to the HTTP layer. That's a smell, and it blocks the LLM work. So I pulled it into its own module behind a contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# backend/categorization.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Protocol&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Categorizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Protocol&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;The contract every categorizer must satisfy.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;categorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RulesCategorizer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Keyword-rules implementation of the Categorizer contract.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CATEGORY_RULES&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_rules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rules&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;categorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keywords&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_rules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keyword&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="c1"&gt;# The single instance the rest of the app imports.
&lt;/span&gt;&lt;span class="n"&gt;default_categorizer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Categorizer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RulesCategorizer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three ideas clicked here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;Protocol&lt;/code&gt; is a contract.&lt;/strong&gt; "Anything called a &lt;code&gt;Categorizer&lt;/code&gt; must have &lt;code&gt;categorize(description) -&amp;gt; str | None&lt;/code&gt;." A class satisfies it just by &lt;em&gt;having&lt;/em&gt; that method — no inheritance (structural typing). My future &lt;code&gt;LLMCategorizer&lt;/code&gt; will satisfy the same contract for free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A class carries state.&lt;/strong&gt; A bare function can't remember things. The LLM version will need to — an API client, and eventually a per-merchant cache. So a class, with &lt;code&gt;self._rules&lt;/code&gt; on the instance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One entry point.&lt;/strong&gt; Everything imports &lt;code&gt;default_categorizer&lt;/code&gt; and calls &lt;code&gt;.categorize(...)&lt;/code&gt;, blissfully ignorant of &lt;em&gt;how&lt;/em&gt; it works. Swapping rules → LLM becomes a one-line change in &lt;strong&gt;one&lt;/strong&gt; place.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then &lt;code&gt;main.py&lt;/code&gt; just does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;categorization&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;default_categorizer&lt;/span&gt;
&lt;span class="c1"&gt;# ...
&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;default_categorizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;categorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Refactor discipline:&lt;/strong&gt; I shipped the extraction as a &lt;em&gt;pure, behavior-neutral&lt;/em&gt; commit — same inputs, same outputs — and only &lt;em&gt;then&lt;/em&gt;, in a separate commit, widened the Food keywords. Never mix a refactor with a behavior change in the same commit. When something breaks later, you want &lt;code&gt;git bisect&lt;/code&gt; to land on one or the other, not a tangle of both.&lt;/p&gt;




&lt;p&gt;&lt;a&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6 — The UI loop: show, add, edit
&lt;/h2&gt;

&lt;p&gt;Auto-categorization is useless if the human can't see or override it. Three small React slices:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Show&lt;/strong&gt; — a category pill per row, with a loud fallback for the ones the rules missed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"mt-1 self-start rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"mt-1 self-start rounded-full bg-red-100 border border-dashed border-slate-300 px-2 py-0.5 text-xs italic text-slate-400"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    Uncategorized
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I made the "Uncategorized" pill &lt;strong&gt;red&lt;/strong&gt; on purpose — it's a gentle nudge that says "this one still needs you."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add&lt;/strong&gt; — an optional category input on the create form, plus defaulting the date to today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Edit&lt;/strong&gt; — the same override on the inline edit form, so those red flags are &lt;em&gt;actionable&lt;/em&gt;: click, type a category (or clear it), save.&lt;/p&gt;

&lt;p&gt;Both forms share one trick that turned out to be load-bearing — which brings me to the bugs.&lt;/p&gt;




&lt;p&gt;&lt;a&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  🐛 The war story: three ways "empty" lied to me
&lt;/h2&gt;

&lt;p&gt;Phase 6 had one signature bug (a CSV that only wrote its last row, traced to an indentation slip). Phase 7a had a &lt;em&gt;theme&lt;/em&gt;: every single bug this session was about the difference between &lt;strong&gt;null&lt;/strong&gt;, &lt;strong&gt;empty string&lt;/strong&gt;, &lt;strong&gt;omitted&lt;/strong&gt;, and &lt;strong&gt;the wrong day&lt;/strong&gt;. "Nothing" is not one thing. It's at least four, and they don't behave the same.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha 1 — Nullable ≠ optional (the silent 422)
&lt;/h3&gt;

&lt;p&gt;I declared the create field like this and thought I was done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# BROKEN — required, but allowed to be null
&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Omit &lt;code&gt;category&lt;/code&gt; in the request body → &lt;strong&gt;422 Field required&lt;/strong&gt;. In Pydantic v2, &lt;code&gt;| None&lt;/code&gt; only says "null is an &lt;em&gt;allowed value&lt;/em&gt;." What makes a field &lt;em&gt;skippable&lt;/em&gt; is a &lt;strong&gt;default&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# FIXED — truly optional
&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;| None&lt;/code&gt; = "null is allowed." &lt;code&gt;= None&lt;/code&gt; = "you can omit it." You need &lt;strong&gt;both&lt;/strong&gt; for "optional and nullable." Two different questions; I'd only answered one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha 2 — Empty string sneaks past the categorizer
&lt;/h3&gt;

&lt;p&gt;My backend auto-fills only when category is &lt;code&gt;None&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;categorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But an HTML text input never gives you &lt;code&gt;None&lt;/code&gt;. Empty it, and it hands you &lt;code&gt;""&lt;/code&gt;. So the naive form body...&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BROKEN — always sends a string, even when blank&lt;/span&gt;
&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;spent_on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;spentOn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;category&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;...sends &lt;code&gt;category: ""&lt;/code&gt;. And &lt;code&gt;"" is None&lt;/code&gt; → &lt;code&gt;False&lt;/code&gt;. So the backend treats an empty box as a deliberate override, &lt;strong&gt;skips &lt;code&gt;categorize()&lt;/code&gt; entirely&lt;/strong&gt;, and every blank-form expense silently lands uncategorized. My shiny new auto-categorization looked completely broken — because of a frontend empty string.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// FIXED — blank (or whitespace) becomes real null&lt;/span&gt;
&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;spent_on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;spentOn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"".trim()&lt;/code&gt; is falsy → &lt;code&gt;|| null&lt;/code&gt; kicks in → the backend sees &lt;code&gt;None&lt;/code&gt; → auto-categorizes. &lt;code&gt;.trim()&lt;/code&gt; also stops someone "overriding" with three spaces. I used the exact same line on the edit form, where clearing the box is how you send a category back to &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha 3 — "Today" was yesterday (a UTC off-by-one)
&lt;/h3&gt;

&lt;p&gt;I wanted the add form to default the date to today. The one-liner everyone reaches for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BROKEN — this is today in UTC&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;today&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;toISOString()&lt;/code&gt; returns &lt;strong&gt;UTC&lt;/strong&gt;. I'm in IST (UTC+5:30). So between midnight and ~5:30 AM my time, UTC is still on &lt;em&gt;yesterday's&lt;/em&gt; date, and the form would confidently pre-fill the wrong day. A bug that only appears before breakfast is the worst kind.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// FIXED — build the string from LOCAL parts&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getTodayString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFullYear&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getMonth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 0-indexed!&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;day&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDate&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;month&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;day&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same &lt;code&gt;getMonth() + 1&lt;/code&gt; / &lt;code&gt;padStart&lt;/code&gt; pattern I'd already written for the CSV filename in Phase 6 — reused, not reinvented.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The through-line:&lt;/strong&gt; null, empty string, omitted, and UTC-vs-local are four different flavors of "nothing/edge," and conflating any two of them is a bug. Now I check &lt;em&gt;which&lt;/em&gt; kind of empty I'm actually dealing with.&lt;/p&gt;




&lt;p&gt;&lt;a&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Thinking like an attacker
&lt;/h2&gt;

&lt;p&gt;The habit from earlier phases that keeps paying off — actively try to break my own feature:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Whitespace-only category?&lt;/strong&gt; &lt;code&gt;.trim() || null&lt;/code&gt; → treated as blank. ✅&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;value={null}&lt;/code&gt; on a controlled input?&lt;/strong&gt; React silently switches the input to &lt;em&gt;uncontrolled&lt;/em&gt; and warns. Coalesced the edit prefill with &lt;code&gt;expense.category || ""&lt;/code&gt;. ✅&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Editing description shouldn't nuke the category.&lt;/strong&gt; The update uses &lt;code&gt;model_dump(exclude_unset=True)&lt;/code&gt;, so omitted fields are left untouched. Sending only &lt;code&gt;{"description": "..."}&lt;/code&gt; preserves the category. ✅&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The substring trap I &lt;em&gt;haven't&lt;/em&gt; fixed yet:&lt;/strong&gt; &lt;code&gt;"gas"&lt;/code&gt; is a keyword under Utilities — and it's a substring of "Ve*&lt;em&gt;gas&lt;/em&gt;*". So &lt;code&gt;"Vegas trip"&lt;/code&gt; categorizes as Utilities. This is the fundamental ceiling of keyword rules, and it's exactly the wall the LLM in Phase 7b is meant to break through. Logged, not hidden.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;a&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Learning shortcut vs. production
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;I did (learning)&lt;/th&gt;
&lt;th&gt;Production would&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hardcoded &lt;code&gt;CATEGORY_RULES&lt;/code&gt; dict&lt;/td&gt;
&lt;td&gt;Config/DB-driven, editable without a deploy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free-text category strings&lt;/td&gt;
&lt;td&gt;A constrained enum or a &lt;code&gt;categories&lt;/code&gt; table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cp expenses.db expenses.backup.db&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Automated, tested DB backups + a restore drill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No re-categorize on description edit&lt;/td&gt;
&lt;td&gt;Probably offer "re-run categorization" as an explicit action&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Substring matching&lt;/td&gt;
&lt;td&gt;Word-boundary matching, or skip straight to the model&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these are wrong for where I am. They're just &lt;em&gt;known&lt;/em&gt; — written down, not pretended away.&lt;/p&gt;




&lt;p&gt;&lt;a&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Key habits to keep
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build the seam before the risky dependency.&lt;/strong&gt; The &lt;code&gt;Categorizer&lt;/code&gt; interface exists so the LLM is a plug-in, not a rewrite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One pure refactor per commit.&lt;/strong&gt; Never bundle "moved code" with "changed behavior."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review every migration before applying it.&lt;/strong&gt; Autogenerate is a draft, not a decree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know which "empty" you mean.&lt;/strong&gt; null ≠ "" ≠ omitted ≠ UTC-today. Four bugs wearing one costume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give the human the final say.&lt;/strong&gt; However good auto-fill gets, the override is the point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log the limitations you're not fixing yet.&lt;/strong&gt; "Vegas → Utilities" is a bug I &lt;em&gt;chose&lt;/em&gt; to defer, not one I missed.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;a&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Next up: Phase 7b
&lt;/h2&gt;

&lt;p&gt;The column holds a category. The rules fill it. The interface is ready. The human can override it everywhere.&lt;/p&gt;

&lt;p&gt;Now the fun part: an &lt;code&gt;LLMCategorizer&lt;/code&gt; that satisfies the exact same &lt;code&gt;Categorizer&lt;/code&gt; contract — so &lt;code&gt;main.py&lt;/code&gt; never knows the difference — with a config toggle, per-merchant caching so I'm not paying for the same "Starbucks" twice, and a rules fallback for when the model is slow, down, or just wrong. The whole point of the seam was to make that a &lt;em&gt;drop-in&lt;/em&gt;. Phase 7b is where I find out if I actually earned it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;See you in the next one. — silentcarry&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Phase 6 — Give Me the Receipts: Date-Range Reports &amp; CSV Export in FastAPI</title>
      <dc:creator>Azhar Alvi </dc:creator>
      <pubDate>Fri, 07 Aug 2026 18:26:51 +0000</pubDate>
      <link>https://dev.to/silentcarry/phase-6-give-me-the-receipts-date-range-reports-csv-export-in-fastapi-5acm</link>
      <guid>https://dev.to/silentcarry/phase-6-give-me-the-receipts-date-range-reports-csv-export-in-fastapi-5acm</guid>
      <description>&lt;p&gt;Tagline: Let the database do the filtering, let Python do the formatting, and never hand-build a CSV.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;By the end of Phase 5 my app could already tell me how much I'd spent this month and whether I was up or down versus last month. Nice numbers on a dashboard. But numbers on a screen aren't something you can &lt;em&gt;do&lt;/em&gt; anything with — you can't hand them to an accountant, drop them into a spreadsheet, or keep a copy.&lt;/p&gt;

&lt;p&gt;So Phase 6 was about one deceptively small idea: &lt;strong&gt;let me pull my expenses for a date range, and let me download them as a CSV.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It sounds like a five-minute job. And the happy path &lt;em&gt;is&lt;/em&gt; short. But doing it properly — query parameters that validate themselves, a file download that actually streams, and an authenticated button on the frontend — surfaced a few things that "just write an endpoint" glosses over. Here's what I actually did, and why, including the one bug that had me squinting at an indentation level.&lt;/p&gt;

&lt;h2&gt;
  
  
  Index
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Structure of this log&lt;/li&gt;
&lt;li&gt;Step 1: The date-range report endpoint (query parameters + validation)&lt;/li&gt;
&lt;li&gt;Step 2: The CSV export endpoint (csv + StringIO + StreamingResponse)&lt;/li&gt;
&lt;li&gt;The bug: my CSV only had one row&lt;/li&gt;
&lt;li&gt;Step 3: Don't Repeat Yourself — extracting a shared helper&lt;/li&gt;
&lt;li&gt;Step 4: The Export CSV button (why a plain link won't work)&lt;/li&gt;
&lt;li&gt;Key habits to keep&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Structure of this log
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Add an authenticated, owner-scoped report endpoint that filters by an explicit start/end date range&lt;/li&gt;
&lt;li&gt;Add a CSV export endpoint that streams a real file download&lt;/li&gt;
&lt;li&gt;Refactor the shared logic into one helper so the two endpoints can't drift apart&lt;/li&gt;
&lt;li&gt;Wire an Export CSV button into the React dashboard&lt;/li&gt;
&lt;li&gt;Write down the snags and the "why", so future-me doesn't relearn them&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: The date-range report endpoint
&lt;/h2&gt;

&lt;p&gt;Every endpoint I'd built until now took either a JSON body (&lt;code&gt;payload: ExpenseCreate&lt;/code&gt;) or a value baked &lt;em&gt;into the URL path&lt;/em&gt; (&lt;code&gt;expense_id&lt;/code&gt; in &lt;code&gt;/expenses/{expense_id}&lt;/code&gt;). A report needs neither. It needs the caller to say "give me the expenses &lt;em&gt;between these two dates&lt;/em&gt;." That's what &lt;strong&gt;query parameters&lt;/strong&gt; are for — the &lt;code&gt;?key=value&lt;/code&gt; bits on the end of a URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /reports/monthly?start=2026-08-01&amp;amp;end=2026-08-31
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The rule I learned for query params
&lt;/h3&gt;

&lt;p&gt;In FastAPI, any function parameter that (a) isn't part of the path and (b) is a plain scalar type automatically becomes a &lt;strong&gt;query parameter&lt;/strong&gt;, read from the URL. Give it no default value and it's &lt;strong&gt;required&lt;/strong&gt; — FastAPI returns a &lt;code&gt;422&lt;/code&gt; on its own if the caller leaves it out. No &lt;code&gt;if not start:&lt;/code&gt; boilerplate needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two layers of validation
&lt;/h3&gt;

&lt;p&gt;This was the part I didn't want to skip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1 — free validation from the type hint.&lt;/strong&gt; By typing the params as &lt;code&gt;date&lt;/code&gt;, FastAPI parses &lt;code&gt;"2026-08-01"&lt;/code&gt; into a real Python &lt;code&gt;date&lt;/code&gt; for me &lt;em&gt;and&lt;/em&gt; auto-rejects garbage like &lt;code&gt;"banana"&lt;/code&gt; or &lt;code&gt;"2026-13-40"&lt;/code&gt; with a &lt;code&gt;422&lt;/code&gt; before my function even runs. Same "let the types do the validating" lesson as Pydantic schemas, just applied to the URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2 — validation the types can't do.&lt;/strong&gt; FastAPI can confirm each value is &lt;em&gt;a&lt;/em&gt; valid date. It has no idea whether &lt;code&gt;start&lt;/code&gt; is supposed to come &lt;em&gt;before&lt;/em&gt; &lt;code&gt;end&lt;/code&gt;. That's business logic, so I check it myself and raise a &lt;code&gt;400&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;422&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;start=banana&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The request is malformed — FastAPI can't even parse it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;400&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;start&lt;/code&gt; after &lt;code&gt;end&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The request parsed fine, but it's &lt;em&gt;logically&lt;/em&gt; wrong&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The distinction matters: &lt;code&gt;422&lt;/code&gt; is "I can't read this," &lt;code&gt;400&lt;/code&gt; is "I read it, and it doesn't make sense."&lt;/p&gt;

&lt;h3&gt;
  
  
  Inclusive range vs the half-open trick
&lt;/h3&gt;

&lt;p&gt;In Phase 5 I used &lt;strong&gt;half-open&lt;/strong&gt; ranges (&lt;code&gt;spent_on &amp;gt;= month_start AND spent_on &amp;lt; next_month_start&lt;/code&gt;) because the upper bound was "the &lt;em&gt;start&lt;/em&gt; of the next month" — half-open is the clean way to not double-count a boundary.&lt;/p&gt;

&lt;p&gt;Here it's different. The user hands me an end date they &lt;em&gt;want included&lt;/em&gt;, and &lt;code&gt;spent_on&lt;/code&gt; is a pure &lt;code&gt;date&lt;/code&gt; column (no time-of-day to trip over). So the intuitive, correct choice is &lt;strong&gt;inclusive on both ends&lt;/strong&gt;: &lt;code&gt;&amp;gt;= start AND &amp;lt;= end&lt;/code&gt;. If I ask for Aug 1 to Aug 31, I expect Aug 31 in the result.&lt;/p&gt;

&lt;p&gt;That's a genuine judgement call, not a rule to memorise: half-open when the boundary is "the start of the next period", inclusive when the user is naming the exact last day they mean.&lt;/p&gt;

&lt;h3&gt;
  
  
  The endpoint
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/reports/monthly&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ExpenseRead&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_monthly_report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start date must be on or before end date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expenses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spent_on&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spent_on&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;expenses&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything else is the same owner-scoped &lt;code&gt;select(...).where(Expense.user_id == current_user.id, ...)&lt;/code&gt; pattern I already trusted from &lt;code&gt;list_expenses&lt;/code&gt;. The only new muscles are the query params and the &lt;code&gt;400&lt;/code&gt; check.&lt;/p&gt;

&lt;h3&gt;
  
  
  How I tested it (the adversarial pass)
&lt;/h3&gt;

&lt;p&gt;I don't just test the happy path any more — I try to &lt;em&gt;break&lt;/em&gt; it the way a real user or an attacker would:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Happy path: &lt;code&gt;start=2026-08-01&lt;/code&gt;, &lt;code&gt;end=2026-08-31&lt;/code&gt; → my August expenses.&lt;/li&gt;
&lt;li&gt;Backwards dates → &lt;code&gt;400&lt;/code&gt; with my message.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;start=banana&lt;/code&gt; → &lt;code&gt;422&lt;/code&gt;, automatically, without my code running.&lt;/li&gt;
&lt;li&gt;A month with no expenses → &lt;code&gt;[]&lt;/code&gt;, not an error.&lt;/li&gt;
&lt;li&gt;No token → &lt;code&gt;401&lt;/code&gt;. (Owner-scoping means another user's rows never show up either.)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 2: The CSV export endpoint
&lt;/h2&gt;

&lt;p&gt;Now the download. Three ideas I hadn't used before.&lt;/p&gt;

&lt;h3&gt;
  
  
  Idea 1 — the &lt;code&gt;csv&lt;/code&gt; module
&lt;/h3&gt;

&lt;p&gt;My first instinct was to build CSV text by hand with f-strings and commas. That breaks the first time a description contains a comma — &lt;code&gt;Lunch, coffee&lt;/code&gt; would split into two columns and shift every field after it. Python's built-in &lt;strong&gt;&lt;code&gt;csv&lt;/code&gt; module&lt;/strong&gt; handles all the escaping: hand &lt;code&gt;csv.writer&lt;/code&gt; a row as a list, and it writes a correctly-quoted line. The comma-in-your-data attacker loses for free.&lt;/p&gt;

&lt;h3&gt;
  
  
  Idea 2 — &lt;code&gt;io.StringIO&lt;/code&gt;, a file that lives in memory
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;csv.writer&lt;/code&gt; needs something &lt;em&gt;file-like&lt;/em&gt; to write into, but I don't want to create a real file on disk just to hand it back over HTTP. &lt;strong&gt;&lt;code&gt;io.StringIO&lt;/code&gt;&lt;/strong&gt; is an in-memory text buffer that behaves exactly like an open file. &lt;code&gt;csv.writer&lt;/code&gt; writes into it, and then I read the text back out.&lt;/p&gt;

&lt;p&gt;The one non-obvious line is &lt;code&gt;buffer.seek(0)&lt;/code&gt; afterwards: writing leaves the cursor at the &lt;em&gt;end&lt;/em&gt; of the buffer, so I rewind it to the start, or the response reads from the end and hands back nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Idea 3 — &lt;code&gt;StreamingResponse&lt;/code&gt; + download headers
&lt;/h3&gt;

&lt;p&gt;A normal FastAPI return becomes JSON. To make the browser treat the reply as a &lt;strong&gt;downloadable file&lt;/strong&gt;, I return a &lt;code&gt;StreamingResponse&lt;/code&gt; with two things:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;media_type="text/csv"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Tells the client "this is CSV, not JSON"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Content-Disposition: attachment; filename="..."&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;attachment&lt;/code&gt; keyword is what makes the browser &lt;em&gt;download and name&lt;/em&gt; the file instead of showing it inline&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The endpoint
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/reports/monthly.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;export_monthly_report_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start date must be on or before end date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expenses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spent_on&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spent_on&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="nb"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StringIO&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;writer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writerow&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spent_on&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;expense&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;expenses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writerow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spent_on&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nb"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seek&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expenses_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;StreamingResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nb"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;media_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text/csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Disposition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;attachment; filename=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: &lt;code&gt;csv&lt;/code&gt;, &lt;code&gt;io&lt;/code&gt;, and &lt;code&gt;StreamingResponse&lt;/code&gt; are all standard library or already shipped with FastAPI — no new packages, so &lt;code&gt;requirements.txt&lt;/code&gt; didn't change.&lt;/p&gt;

&lt;p&gt;Learning shortcut vs production version: I build the whole CSV in memory first, which is perfect for a personal expense tracker. For a huge export you'd &lt;code&gt;yield&lt;/code&gt; rows from a generator so you never hold the whole file in RAM — same &lt;code&gt;StreamingResponse&lt;/code&gt;, just fed a generator instead of a buffer. Good to know it exists; not needed yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: my CSV only had one row
&lt;/h2&gt;

&lt;p&gt;First download: header row, then… one expense. I had more than one in August, and Step 1's report endpoint (same query!) returned all of them. So the data wasn't the problem — the &lt;em&gt;writing&lt;/em&gt; was.&lt;/p&gt;

&lt;p&gt;The tell was &lt;em&gt;which&lt;/em&gt; expense showed up. It was the &lt;strong&gt;last&lt;/strong&gt; one. That points at exactly one thing: my &lt;code&gt;writer.writerow(...)&lt;/code&gt; had drifted &lt;em&gt;out&lt;/em&gt; of the &lt;code&gt;for&lt;/code&gt; loop. Python happily ran the loop doing nothing, then wrote the loop variable — left pointing at the final expense — exactly once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# BROKEN — writerow is dedented, so it runs once after the loop
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;expense&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;expenses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writerow&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spent_on&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="c1"&gt;# FIXED — writerow is indented inside the loop, so it runs per expense
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;expense&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;expenses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writerow&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spent_on&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a language where indentation &lt;em&gt;is&lt;/em&gt; the block structure, a single wrong indent level isn't a style nit — it changes what runs when. Lesson filed: when a loop "only does the last thing once", check the indentation before you check anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Don't Repeat Yourself — extracting a shared helper
&lt;/h2&gt;

&lt;p&gt;With both endpoints working, I noticed they now carried the &lt;em&gt;identical&lt;/em&gt; validation-plus-query block. That's a maintenance trap: the day I change the date logic (say, exclude refunds later), I have to remember to change it in &lt;em&gt;both&lt;/em&gt; places, and the bug is that I'll forget one.&lt;/p&gt;

&lt;p&gt;So I pulled the shared work into a single helper — the &lt;strong&gt;one source of truth&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_expenses_in_range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start date must be on or before end date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spent_on&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spent_on&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two small things I learned here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The leading underscore.&lt;/strong&gt; I already knew &lt;code&gt;_&lt;/code&gt; as the throwaway-value convention. A leading underscore on a &lt;em&gt;function name&lt;/em&gt; is a related-but-different convention: "this is an internal helper for this module, not a public endpoint." No decorator, so FastAPI never exposes it as a route.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raising from a helper is fine.&lt;/strong&gt; I worried the &lt;code&gt;400&lt;/code&gt; might get swallowed. It doesn't — FastAPI catches &lt;code&gt;HTTPException&lt;/code&gt; no matter how deep it's raised, so the status still reaches the client.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The report endpoint's body then collapses to a single line, and the CSV endpoint keeps only its CSV-building:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/reports/monthly&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ExpenseRead&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_monthly_report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_expenses_in_range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was a "prove I changed nothing observable" refactor: I re-ran the same adversarial checks on both endpoints and confirmed identical behaviour. Green before, green after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: The Export CSV button (why a plain link won't work)
&lt;/h2&gt;

&lt;p&gt;Last piece — the button. My first instinct was a plain &lt;code&gt;&amp;lt;a href="...monthly.csv" download&amp;gt;&lt;/code&gt;. That does &lt;strong&gt;not&lt;/strong&gt; work here, and the reason is worth internalising: &lt;strong&gt;a normal link navigation can't attach an &lt;code&gt;Authorization&lt;/code&gt; header.&lt;/strong&gt; My CSV endpoint is owner-scoped and needs &lt;code&gt;Bearer &amp;lt;token&amp;gt;&lt;/code&gt; — a bare anchor click sends no token, so it'd just get a &lt;code&gt;401&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So I do the download in the code path I actually control:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;fetch&lt;/code&gt; the endpoint &lt;em&gt;with&lt;/em&gt; the auth header (same as every other authed call).&lt;/li&gt;
&lt;li&gt;Read the body as a &lt;strong&gt;&lt;code&gt;Blob&lt;/code&gt;&lt;/strong&gt; via &lt;code&gt;response.blob()&lt;/code&gt; — a Blob is an in-memory chunk of file data, the right shape for a file (unlike &lt;code&gt;response.json()&lt;/code&gt;, which I use for data).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;URL.createObjectURL(blob)&lt;/code&gt; mints a temporary in-browser URL pointing at that blob.&lt;/li&gt;
&lt;li&gt;Create an &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; in code, set &lt;code&gt;href&lt;/code&gt; to that URL and the &lt;code&gt;download&lt;/code&gt; attribute to the filename, then programmatically &lt;code&gt;click()&lt;/code&gt; it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;URL.revokeObjectURL(url)&lt;/code&gt; frees the memory afterward — the object URL pins the blob in memory until revoked. Skipping it is a small leak (the "left the tap running" case).
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleExportCsv&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Build the current month's range as YYYY-MM-DD strings.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFullYear&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getMonth&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// getMonth() is 0-indexed (Jan = 0)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;pad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;month&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;-01`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lastDay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getDate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// day 0 of next month = last day of this one&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;pad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;month&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;pad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastDay&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`http://localhost:8000/reports/monthly.csv?start=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;end=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;onAuthError&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Export failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;link&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;download&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`expenses_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.csv`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;revokeObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two date lines earned a second look. &lt;code&gt;getMonth()&lt;/code&gt; is &lt;strong&gt;0-indexed&lt;/strong&gt;, so I add 1 for display. And &lt;code&gt;new Date(year, month + 1, 0)&lt;/code&gt; is the classic "last day of the month" trick — day zero of the &lt;em&gt;next&lt;/em&gt; month rolls back to the last day of &lt;em&gt;this&lt;/em&gt; one. &lt;code&gt;padStart(2, "0")&lt;/code&gt; makes sure I send &lt;code&gt;08&lt;/code&gt;, not &lt;code&gt;8&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I also reused my existing &lt;code&gt;401 → onAuthError()&lt;/code&gt; pattern, so an expired token during export logs me out cleanly instead of silently failing — consistent with the rest of the app. Hand-rolling date strings is fiddly, though; letting the user pick the month (or using a date library) is a natural future upgrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key habits to keep
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Let the database filter, let Python format.&lt;/strong&gt; The &lt;code&gt;WHERE&lt;/code&gt; clause narrows the rows; Python only shapes what's left into CSV. Don't fetch everything and filter in a loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Types validate your inputs for free — use them.&lt;/strong&gt; Typing a query param as &lt;code&gt;date&lt;/code&gt; gave me parsing and a &lt;code&gt;422&lt;/code&gt; on bad input with zero extra code. Reserve hand-written checks for the logic types can't express (like &lt;code&gt;start &amp;lt;= end&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;400&lt;/code&gt; vs &lt;code&gt;422&lt;/code&gt; is a real distinction.&lt;/strong&gt; "I can't read this" is not the same as "I read it and it's wrong." Return the one that tells the caller the truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never build a CSV by hand.&lt;/strong&gt; The &lt;code&gt;csv&lt;/code&gt; module exists precisely because commas and quotes in your data will bite you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When a loop only does the last thing once, suspect the indentation.&lt;/strong&gt; In Python, an indent level is control flow, not decoration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DRY the moment you see the second copy.&lt;/strong&gt; Two identical blocks is one bug waiting to be half-fixed. A tiny helper is cheaper than the drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A download that needs auth can't be a plain link.&lt;/strong&gt; Fetch with the header, turn the response into a Blob, and trigger the download yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Phase 6 turned a dashboard of numbers into something I can actually take with me. Next up: Phase 7, where categories finally enter the schema and the app starts to get opinionated about &lt;em&gt;what&lt;/em&gt; I'm spending on.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>fastapi</category>
      <category>python</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Phase 5: The Numbers Start Talking</title>
      <dc:creator>Azhar Alvi </dc:creator>
      <pubDate>Fri, 07 Aug 2026 05:50:47 +0000</pubDate>
      <link>https://dev.to/silentcarry/phase-5-the-numbers-start-talking-9on</link>
      <guid>https://dev.to/silentcarry/phase-5-the-numbers-start-talking-9on</guid>
      <description>&lt;p&gt;My expense tracker could already remember everything.&lt;/p&gt;

&lt;p&gt;It could register users, hash passwords, issue JWTs, isolate one user's expenses from another's, and do complete CRUD through a responsive React UI.&lt;/p&gt;

&lt;p&gt;That was the end of &lt;a href="https://dev.to/silentcarry/phase-4-part-2-the-deviation-144p"&gt;Phase 4, Part 2: The Deviation&lt;/a&gt;: the app had a face, expired sessions returned me cleanly to login instead of leaving me stranded, the components had clearer jobs, and Tailwind had made the whole thing look less like a form recovered from an abandoned government portal.&lt;/p&gt;

&lt;p&gt;Useful progress.&lt;/p&gt;

&lt;p&gt;The code for this learning project lives in &lt;a href="https://github.com/AzharSaeedAlvi/Smart-Expense-Manager" rel="noopener noreferrer"&gt;Smart Expense Manager on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;But the app still had no opinion.&lt;/p&gt;

&lt;p&gt;It could show me every expense I had entered, but it could not answer the first question a normal person would ask an expense manager:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How much have I spent this month?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a different kind of feature.&lt;/p&gt;

&lt;p&gt;CRUD asks the database to preserve and retrieve individual records.&lt;/p&gt;

&lt;p&gt;Insights ask the database to look across many records and produce meaning.&lt;/p&gt;

&lt;p&gt;So Phase 5 was where the app stopped being a digital shoebox and started becoming a dashboard.&lt;/p&gt;

&lt;p&gt;Or at least that was the plan.&lt;/p&gt;

&lt;p&gt;Because before I wrote the first aggregate query, the roadmap tried to make me invent data that did not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The roadmap collision [the field I did not have]
&lt;/h2&gt;

&lt;p&gt;My Phase 5 roadmap called for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Total spent this month&lt;/li&gt;
&lt;li&gt;Spend by category&lt;/li&gt;
&lt;li&gt;Top merchants&lt;/li&gt;
&lt;li&gt;Month-over-month comparison&lt;/li&gt;
&lt;li&gt;A chart&lt;/li&gt;
&lt;li&gt;Plain-language insights&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reasonable dashboard features.&lt;/p&gt;

&lt;p&gt;One small problem: my &lt;code&gt;Expense&lt;/code&gt; model did not have &lt;code&gt;category&lt;/code&gt; or &lt;code&gt;merchant&lt;/code&gt; fields.&lt;/p&gt;

&lt;p&gt;It had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;id
user_id
amount
description
spent_on
created_at
updated_at
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Category is not scheduled until Phase 7.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;description&lt;/code&gt; is not automatically a merchant just because both are strings. An expense described as &lt;code&gt;Dinner after client meeting&lt;/code&gt; cannot honestly become a merchant named &lt;code&gt;Dinner after client meeting&lt;/code&gt; because the roadmap would like a leaderboard.&lt;/p&gt;

&lt;p&gt;This was the first decision of Phase 5, and no code was involved:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not manufacture a schema to make the roadmap look complete.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I could have pulled categorization forward, changed the database, created a migration, updated every schema and form, and turned one learning objective into six simultaneous changes.&lt;/p&gt;

&lt;p&gt;That would have made the dashboard look busier.&lt;/p&gt;

&lt;p&gt;It would also have destroyed the sequencing I had deliberately designed.&lt;/p&gt;

&lt;p&gt;So I took the smallest vertical slice that used fields I actually had:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Calculate the authenticated user's total for the current month in SQL.&lt;/li&gt;
&lt;li&gt;Verify it by hand.&lt;/li&gt;
&lt;li&gt;Show it in React.&lt;/li&gt;
&lt;li&gt;Make it refresh after CRUD changes.&lt;/li&gt;
&lt;li&gt;Then compare the current month with the previous one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Category spend, merchant rankings, and the category chart remain parked until the model can support them honestly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha #15 — A roadmap dependency is not permission to lie to your model
&lt;/h3&gt;

&lt;p&gt;This felt like a planning problem, but it was really a data-integrity problem.&lt;/p&gt;

&lt;p&gt;A dashboard can only be as honest as the fields underneath it.&lt;/p&gt;

&lt;p&gt;If the database does not distinguish a description from a merchant, the UI should not pretend it does.&lt;/p&gt;

&lt;p&gt;The correct move was not to force Phase 5 to match every bullet immediately. It was to identify the dependency, document it, and build the valid slice first.&lt;/p&gt;

&lt;p&gt;A roadmap is a guide.&lt;/p&gt;

&lt;p&gt;The repository is ground truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first real aggregate [let the database add]
&lt;/h2&gt;

&lt;p&gt;Before this phase, most of my queries returned rows.&lt;/p&gt;

&lt;p&gt;Find this expense.&lt;/p&gt;

&lt;p&gt;List this user's expenses.&lt;/p&gt;

&lt;p&gt;Update this row.&lt;/p&gt;

&lt;p&gt;Delete that row.&lt;/p&gt;

&lt;p&gt;A monthly total is different. I do not want every matching expense back. I want one value produced from all of them.&lt;/p&gt;

&lt;p&gt;That is an &lt;strong&gt;aggregate&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The deliberately inefficient version would be:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetch all the user's expenses.&lt;/li&gt;
&lt;li&gt;Move every row from SQLite into Python.&lt;/li&gt;
&lt;li&gt;Loop through them.&lt;/li&gt;
&lt;li&gt;Check each date.&lt;/li&gt;
&lt;li&gt;Add each amount.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That would work on my tiny local database.&lt;/p&gt;

&lt;p&gt;It would also teach the wrong habit.&lt;/p&gt;

&lt;p&gt;Databases are built to filter and aggregate data. SQL can do the work close to where the data lives and return one number instead of shipping a pile of rows into application memory.&lt;/p&gt;

&lt;p&gt;The shape I wanted was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;expenses&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;spent_on&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;spent_on&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;...;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In SQLAlchemy 2.0, that became:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spent_on&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;month_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spent_on&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;next_month_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are several ideas packed into that small block.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;func.sum(...)&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;func&lt;/code&gt; is SQLAlchemy's bridge to SQL functions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;means: ask the database to add the matching &lt;code&gt;amount&lt;/code&gt; values.&lt;/p&gt;

&lt;p&gt;Python is not performing that addition.&lt;/p&gt;

&lt;p&gt;SQLite is.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;db.scalar(...)&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The query returns one cell: the total.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;db.scalar(...)&lt;/code&gt; executes the statement and gives me that single value directly.&lt;/p&gt;

&lt;p&gt;That is a better match than asking for a collection result and then digging one number out of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Owner scope still applies
&lt;/h3&gt;

&lt;p&gt;The most important condition is easy to overlook because it is not mathematically interesting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without it, the total would combine every user's expenses.&lt;/p&gt;

&lt;p&gt;The CRUD endpoints already close the IDOR hole by scoping records to the authenticated owner. Aggregate endpoints need the same discipline.&lt;/p&gt;

&lt;p&gt;A dashboard number can leak data just as easily as a detail endpoint.&lt;/p&gt;

&lt;p&gt;Possibly more quietly, because the user never sees the underlying rows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha #16 — &lt;code&gt;SUM()&lt;/code&gt; over no rows does not naturally give zero
&lt;/h3&gt;

&lt;p&gt;My human expectation was simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;No expenses means total spending is zero.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;SQL's natural answer is different.&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;SUM()&lt;/code&gt; has no matching rows, it returns &lt;code&gt;NULL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is not the same as zero. &lt;code&gt;NULL&lt;/code&gt; means there was no value to aggregate.&lt;/p&gt;

&lt;p&gt;Useful database semantics; awkward dashboard semantics.&lt;/p&gt;

&lt;p&gt;So I wrapped the sum with &lt;code&gt;COALESCE&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;COALESCE&lt;/code&gt; returns the first non-null value.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;SUM()&lt;/code&gt; returns a real total, keep it.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;SUM()&lt;/code&gt; returns &lt;code&gt;NULL&lt;/code&gt;, use &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The endpoint can now promise a number-shaped result even for a brand-new user.&lt;/p&gt;

&lt;p&gt;I gave that result an explicit Pydantic response model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MonthlyTotalRead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the endpoint returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"6000.00"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The quotes are expected. My money values use &lt;code&gt;Decimal&lt;/code&gt;, and they serialize as strings so JSON does not quietly introduce floating-point surprises.&lt;/p&gt;

&lt;h2&gt;
  
  
  The date filter [where most of the danger lived]
&lt;/h2&gt;

&lt;p&gt;Adding numbers was the easy part.&lt;/p&gt;

&lt;p&gt;Defining “this month” correctly was the part trying to become a bug.&lt;/p&gt;

&lt;p&gt;A tempting filter is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spent_on&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;month_start&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the current month starts on August 1, that includes every expense from August 1 onward.&lt;/p&gt;

&lt;p&gt;Including September.&lt;/p&gt;

&lt;p&gt;And October.&lt;/p&gt;

&lt;p&gt;And any future-dated expense I accidentally entered.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha #17 — A lower boundary without an upper boundary is not “this month”
&lt;/h3&gt;

&lt;p&gt;The safe definition is a half-open range:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;current_start &amp;lt;= spent_on &amp;lt; next_start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For August 2026:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;August 1 &amp;lt;= spent_on &amp;lt; September 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why &lt;code&gt;&amp;lt; September 1&lt;/code&gt; instead of calculating the final date of August?&lt;/p&gt;

&lt;p&gt;Because months have different lengths. The first date of the next month is a cleaner boundary than branching over 28, 29, 30, or 31 possible final dates.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;spent_on&lt;/code&gt; later becomes a timestamp, this same half-open-range idea will also avoid fragile end-of-day precision logic.&lt;/p&gt;

&lt;p&gt;This is the same range idea I will reuse later for reports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Include the start.&lt;/li&gt;
&lt;li&gt;Exclude the next period's start.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No guessing about 28, 29, 30, or 31 days.&lt;/p&gt;

&lt;h2&gt;
  
  
  One helper, three boundaries [and my first useful tuple]
&lt;/h2&gt;

&lt;p&gt;The monthly-total endpoint needed two dates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Current month start&lt;/li&gt;
&lt;li&gt;Next month start&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The month-over-month endpoint would need three:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Previous month start&lt;/li&gt;
&lt;li&gt;Current month start&lt;/li&gt;
&lt;li&gt;Next month start&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I could have copied the date logic into both endpoints.&lt;/p&gt;

&lt;p&gt;That would work until one copy got fixed and the other did not.&lt;/p&gt;

&lt;p&gt;So I moved it into a helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_month_boundaries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;reference_date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;current_start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reference_date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current_start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;previous_start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;previous_start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;current_start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;current_start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current_start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;next_start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;next_start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;current_start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;current_start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;previous_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next_start&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A tuple is an ordered, fixed collection of values.&lt;/p&gt;

&lt;p&gt;The function needs to return three related dates, so it groups them into one tuple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previous_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next_start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python lets me unpack that tuple by position:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;previous_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next_start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_month_boundaries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;today&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the monthly-total endpoint, I do not need the first value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;month_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next_month_start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_month_boundaries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;today&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The underscore is a normal variable with a conventional meaning:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This value exists, but I am intentionally not using it here.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The type hint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;makes the contract visible: exactly three &lt;code&gt;date&lt;/code&gt; values come back.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha #18 — January and December are not normal subtraction problems
&lt;/h3&gt;

&lt;p&gt;This does not work in January:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;current_month&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;January is month &lt;code&gt;1&lt;/code&gt;. Month &lt;code&gt;0&lt;/code&gt; does not exist.&lt;/p&gt;

&lt;p&gt;December has the mirror problem when calculating the next month: month &lt;code&gt;13&lt;/code&gt; does not exist.&lt;/p&gt;

&lt;p&gt;That is why the helper has explicit rollover branches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;January 2026 previous start -&amp;gt; December 1, 2025
December 2026 next start   -&amp;gt; January 1, 2027
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My current test data happened to be in August.&lt;/p&gt;

&lt;p&gt;Production data will eventually arrive in January.&lt;/p&gt;

&lt;p&gt;Calendar code that works only in the month it was written is not calendar code. It is a seasonal demo.&lt;/p&gt;

&lt;p&gt;There is still one deliberate limitation: &lt;code&gt;date.today()&lt;/code&gt; uses the backend machine's local date. For this local learning build, that is acceptable. A production version should define the application's business timezone explicitly so a user near a month boundary does not receive a total based on the server's calendar instead of their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving the total [not just admiring a 200]
&lt;/h2&gt;

&lt;p&gt;The request returned HTTP &lt;code&gt;200&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That proved the route existed and the response matched the schema.&lt;/p&gt;

&lt;p&gt;It did not prove the number was right.&lt;/p&gt;

&lt;p&gt;So I checked the relevant expense amounts by hand and compared their sum with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /insights/monthly-total
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I created an adversarial row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Description: Previous-month test
Amount: 999.99
Spent on: 2026-07-15
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My current-month total had to remain unchanged.&lt;/p&gt;

&lt;p&gt;It did.&lt;/p&gt;

&lt;p&gt;Then I deleted only that temporary row.&lt;/p&gt;

&lt;p&gt;This was the adversarial-testing habit—the project’s running “GAN analysis” joke—in its simplest useful form: give the query awkward data that should be rejected by its boundaries.&lt;/p&gt;

&lt;p&gt;A test with only current-month expenses cannot prove that previous-month filtering works.&lt;/p&gt;

&lt;p&gt;It only proves that current-month rows are included.&lt;/p&gt;

&lt;p&gt;The rejected row was the evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting the number in React [and keeping it alive]
&lt;/h2&gt;

&lt;p&gt;The backend could answer the question now.&lt;/p&gt;

&lt;p&gt;The user still had to open &lt;code&gt;/docs&lt;/code&gt; to hear it.&lt;/p&gt;

&lt;p&gt;So I added a monthly-total request inside &lt;code&gt;ExpenseList&lt;/code&gt;'s existing &lt;code&gt;fetchExpenses()&lt;/code&gt; flow.&lt;/p&gt;

&lt;p&gt;That placement was deliberate.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fetchExpenses()&lt;/code&gt; already runs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When the expense screen loads&lt;/li&gt;
&lt;li&gt;After adding an expense&lt;/li&gt;
&lt;li&gt;After editing an expense&lt;/li&gt;
&lt;li&gt;After deleting an expense&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the total is fetched in the same refresh path, it stays synchronized with the list without inventing another global state owner.&lt;/p&gt;

&lt;p&gt;The state is local to the component that displays it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;monthlyTotal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setMonthlyTotal&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0.00&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The intended authenticated request follows the same shape as the expense request. This is the corrected form I want the code to reach; the later source audit found that the committed file does not fully match it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://localhost:8000/insights/monthly-total&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;onAuthError&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;totalResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`Monthly total request failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;totalResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;totalResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nf"&gt;setMonthlyTotal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the first dashboard metric became a small card:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"mb-6 rounded-lg bg-blue-50 p-4"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-sm font-medium text-blue-700"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    Spent this month
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"mt-1 text-3xl font-bold text-blue-900"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;monthlyTotal&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tested it with another temporary expense:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Description: Monthly metric refresh test
Amount: 1.00
Spent on: 2026-08-07
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The total increased by exactly &lt;code&gt;1.00&lt;/code&gt; without a manual page refresh.&lt;/p&gt;

&lt;p&gt;Then I deleted the temporary row and watched the total return.&lt;/p&gt;

&lt;p&gt;That was the point where the dashboard stopped being decoration.&lt;/p&gt;

&lt;p&gt;It was now a live projection of database state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Month-over-month [two totals, one comparison]
&lt;/h2&gt;

&lt;p&gt;The next valid roadmap item was month-over-month comparison.&lt;/p&gt;

&lt;p&gt;The endpoint needed four values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MonthOverMonthRead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;current_month_total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;
    &lt;span class="n"&gt;previous_month_total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;
    &lt;span class="n"&gt;change_amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;
    &lt;span class="n"&gt;change_percentage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database still performs the aggregation.&lt;/p&gt;

&lt;p&gt;One query sums the previous-month range:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;previous_total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spent_on&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;previous_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spent_on&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;current_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A second query sums the current-month range:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;current_total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spent_on&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;current_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spent_on&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;next_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then Python compares the two already-aggregated numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;change_amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_total&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;previous_total&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is an important boundary between database work and application work.&lt;/p&gt;

&lt;p&gt;SQL is good at finding and summing the relevant rows.&lt;/p&gt;

&lt;p&gt;Python is good at applying the small piece of business logic to the two totals.&lt;/p&gt;

&lt;p&gt;I am not hauling raw expenses into Python to recreate &lt;code&gt;SUM()&lt;/code&gt; badly.&lt;/p&gt;

&lt;p&gt;I am combining two scalar results.&lt;/p&gt;

&lt;h2&gt;
  
  
  The percentage trap [zero has opinions]
&lt;/h2&gt;

&lt;p&gt;The percentage formula is ordinary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(current - previous) / previous × 100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The denominator is not ordinary when the user had no expenses last month.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha #19 — “No previous spending” is not a zero-percent change
&lt;/h3&gt;

&lt;p&gt;Suppose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Previous month: 0
Current month: 6000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is tempting to say spending increased by &lt;code&gt;100%&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It did not.&lt;/p&gt;

&lt;p&gt;There is no finite percentage that turns zero into 6000, because the formula requires division by zero.&lt;/p&gt;

&lt;p&gt;Python will reject that calculation, and mathematically it is undefined.&lt;/p&gt;

&lt;p&gt;So the endpoint handles that state explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;change_percentage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;previous_total&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;change_percentage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;change_amount&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;previous_total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JSON response uses &lt;code&gt;null&lt;/code&gt; when there is no valid percentage baseline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"current_month_total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"6000.00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"previous_month_total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"change_amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"6000.00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"change_percentage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives the frontend enough information to say something honest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;no previous-month spending to compare
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not &lt;code&gt;0% change&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Not &lt;code&gt;100% increase&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Not &lt;code&gt;Infinity%&lt;/code&gt;, which would at least be memorable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signed data versus human language
&lt;/h2&gt;

&lt;p&gt;With real previous-month data, the endpoint might return:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"current_month_total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"6000.00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"previous_month_total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10000.70"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"change_amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-4000.70"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"change_percentage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-40.00"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The math is correct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;6000.00 - 10000.70 = -4000.70
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-4000.70 / 10000.70 × 100 ≈ -40%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But displaying this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-4000.70
40% less than last month
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;felt clumsy.&lt;/p&gt;

&lt;p&gt;The negative sign and the word &lt;code&gt;less&lt;/code&gt; communicate the same direction twice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha #20 — Correct data can still be unclear UI
&lt;/h3&gt;

&lt;p&gt;The backend should preserve the signed value because the sign is useful for logic.&lt;/p&gt;

&lt;p&gt;The frontend can present its absolute magnitude because the words provide the direction.&lt;/p&gt;

&lt;p&gt;So the amount display became:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;monthComparison&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;change_amount&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three small transformations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Number(...)&lt;/code&gt; converts the API's decimal string into a JavaScript number.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Math.abs(...)&lt;/code&gt; removes the sign for presentation.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;toFixed(2)&lt;/code&gt; displays exactly two decimal places.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The API remains truthful:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-4000.70
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The card becomes readable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;4000.70
40% less than last month
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Data representation and presentation are related, but they are not the same job.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plain-English helper [let the card speak]
&lt;/h2&gt;

&lt;p&gt;The comparison API returns one object containing related values, so React stores it as object state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;monthComparison&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setMonthComparison&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;null&lt;/code&gt; means the request has not produced data yet.&lt;/p&gt;

&lt;p&gt;The card renders only when the object exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;monthComparison&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="c1"&gt;// comparison card&lt;/span&gt;
&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the text, I used a small pure helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildComparisonText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;comparison&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;comparison&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;change_percentage&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no previous-month spending to compare&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;percentage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;comparison&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;change_percentage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;percentage&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;percentage&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;% more than last month`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;percentage&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;percentage&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;% less than last month`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Spending is unchanged from last month&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A pure helper receives data and returns a result. It does not change React state, call the API, or mutate the input.&lt;/p&gt;

&lt;p&gt;That makes the possible messages explicit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;More than last month&lt;/li&gt;
&lt;li&gt;Less than last month&lt;/li&gt;
&lt;li&gt;Unchanged&lt;/li&gt;
&lt;li&gt;No valid previous-month baseline&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The frontend is no longer just printing an aggregate.&lt;/p&gt;

&lt;p&gt;It is interpreting it for a person.&lt;/p&gt;

&lt;p&gt;That was one of the actual Phase 5 goals: plain-language insight statements, not just a number farm.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second adversarial test [make the card move]
&lt;/h2&gt;

&lt;p&gt;I reused the controlled previous-month row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Description: Comparison refresh test
Amount: 100.00
Spent on: 2026-07-15
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That row had to do several things at once:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Appear in &lt;code&gt;previous_month_total&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Stay out of &lt;code&gt;current_month_total&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Change &lt;code&gt;change_amount&lt;/code&gt; correctly.&lt;/li&gt;
&lt;li&gt;Produce a valid percentage instead of &lt;code&gt;null&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Refresh the React card automatically after creation.&lt;/li&gt;
&lt;li&gt;Return the card to its original state after deletion.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It passed all six.&lt;/p&gt;

&lt;p&gt;This is the part of testing I am beginning to appreciate: one carefully chosen row can challenge several assumptions at once.&lt;/p&gt;

&lt;p&gt;Then I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run lint
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And from the repository root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git diff &lt;span class="nt"&gt;--check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both passed during the feature work.&lt;/p&gt;

&lt;p&gt;I committed the backend and frontend slices separately so the history preserved the vertical progression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Add monthly spending total endpoint
Show monthly spending total in dashboard
Add month-over-month spending insight
Add month-over-mmonth dashboard insight
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yes, the final commit subject contains &lt;code&gt;mmonth&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Git is a historical record, including the part where my fingers became optimistic.&lt;/p&gt;

&lt;p&gt;I am not rewriting published history over a spelling mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit after the victory lap [source code gets the final vote]
&lt;/h2&gt;

&lt;p&gt;This article would be dishonest if it ended with “everything is hardened and perfect.”&lt;/p&gt;

&lt;p&gt;When I reread the actual repository to prepare this write-up, I found several narrow cleanup items that the happy-path tests had not exposed.&lt;/p&gt;

&lt;p&gt;The important one:&lt;/p&gt;

&lt;p&gt;My previous article said I had placed the &lt;code&gt;401&lt;/code&gt; guard into all authenticated calls, including update.&lt;/p&gt;

&lt;p&gt;The current repository shows that &lt;code&gt;handleUpdate()&lt;/code&gt; does not have the same explicit &lt;code&gt;401&lt;/code&gt; branch before its generic failure path.&lt;/p&gt;

&lt;p&gt;I do not know whether that guard was lost during a later edit or whether my earlier statement outran the committed code.&lt;/p&gt;

&lt;p&gt;I do know which source wins the disagreement.&lt;/p&gt;

&lt;p&gt;The repository.&lt;/p&gt;

&lt;p&gt;I also found smaller issues in &lt;code&gt;ExpenseList.jsx&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;roundded-xl
text-3x1
Comapred with last month
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And one broken error-message interpolation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="s2"&gt;`Monthly total request failed: $(totalResponse.status)`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interpolation syntax should use braces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="s2"&gt;`Monthly total request failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;totalResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is also one loose status comparison using &lt;code&gt;==&lt;/code&gt; where &lt;code&gt;===&lt;/code&gt; is clearer and safer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha #21 — Passing the happy path does not mean the source is clean
&lt;/h3&gt;

&lt;p&gt;None of those typos stopped my normal insight flow from working.&lt;/p&gt;

&lt;p&gt;That is exactly why they survived.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A misspelled Tailwind utility is ignored rather than crashing React.&lt;/li&gt;
&lt;li&gt;A misspelled label still renders.&lt;/li&gt;
&lt;li&gt;A broken error string stays hidden until that error branch runs.&lt;/li&gt;
&lt;li&gt;A missing &lt;code&gt;401&lt;/code&gt; branch stays invisible while the token remains valid.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the adversarial lesson arriving late but usefully:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Code paths I did not trigger still deserve inspection.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the next session begins with a narrow QA cleanup before Phase 6.&lt;/p&gt;

&lt;p&gt;Not a giant refactor.&lt;/p&gt;

&lt;p&gt;Not a shame spiral.&lt;/p&gt;

&lt;p&gt;One focused correction at a time, with the same test-and-commit discipline.&lt;/p&gt;

&lt;p&gt;The point of build-in-public is not to create the illusion that every checkpoint was flawless.&lt;/p&gt;

&lt;p&gt;It is to preserve what I learned when the flaw became visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Phase 5 actually completed
&lt;/h2&gt;

&lt;p&gt;With the current schema, the app can now answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How much did this authenticated user spend this month?&lt;/li&gt;
&lt;li&gt;How much did they spend in the previous month?&lt;/li&gt;
&lt;li&gt;What is the signed difference?&lt;/li&gt;
&lt;li&gt;What is the percentage change when a valid baseline exists?&lt;/li&gt;
&lt;li&gt;Is spending higher, lower, unchanged, or not yet comparable?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dashboard updates those answers after expense mutations.&lt;/p&gt;

&lt;p&gt;The database performs the aggregation.&lt;/p&gt;

&lt;p&gt;The API remains owner-scoped.&lt;/p&gt;

&lt;p&gt;The frontend translates the result into language.&lt;/p&gt;

&lt;p&gt;What Phase 5 did &lt;strong&gt;not&lt;/strong&gt; complete:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Spend by category&lt;/li&gt;
&lt;li&gt;Top merchants&lt;/li&gt;
&lt;li&gt;Category chart&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those remain blocked by the schema, not forgotten.&lt;/p&gt;

&lt;p&gt;Category arrives in Phase 7.&lt;/p&gt;

&lt;p&gt;Merchant does not exist yet as a real field.&lt;/p&gt;

&lt;p&gt;I am leaving both visible in the backlog rather than disguising descriptions as merchant data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model I am keeping
&lt;/h2&gt;

&lt;p&gt;The biggest lesson was not the syntax of &lt;code&gt;SUM()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It was the division of responsibility:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Database:
Find the correct owner's rows inside exact date boundaries and aggregate them.

Backend:
Define the business comparison, handle undefined cases, and return a stable schema.

Frontend:
Fetch the result, keep it synchronized, and translate signed data into readable language.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or, shorter:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;SQL finds and adds. Python decides. React explains.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the first architecture sentence I have written in this project that feels genuinely reusable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stuff I want to remember [the honest takeaways]
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An aggregate turns many rows into one result.&lt;/li&gt;
&lt;li&gt;Let the database perform &lt;code&gt;SUM()&lt;/code&gt; instead of fetching every row into Python.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;db.scalar(...)&lt;/code&gt; is the right shape when the query returns one value.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SUM()&lt;/code&gt; over no rows returns &lt;code&gt;NULL&lt;/code&gt;; &lt;code&gt;COALESCE(..., 0)&lt;/code&gt; gives the dashboard a usable zero.&lt;/li&gt;
&lt;li&gt;Aggregate endpoints need owner scoping just as much as CRUD endpoints.&lt;/li&gt;
&lt;li&gt;“This month” needs both boundaries: &lt;code&gt;&amp;gt;= current_start&lt;/code&gt; and &lt;code&gt;&amp;lt; next_start&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Half-open ranges avoid month-length and end-of-day traps.&lt;/li&gt;
&lt;li&gt;January and December require explicit year rollover.&lt;/li&gt;
&lt;li&gt;A tuple can return several fixed, ordered values from one helper.&lt;/li&gt;
&lt;li&gt;Tuple unpacking assigns by position; &lt;code&gt;_&lt;/code&gt; conventionally marks an intentionally unused value.&lt;/li&gt;
&lt;li&gt;SQL should aggregate the rows; Python can compare the resulting scalar totals.&lt;/li&gt;
&lt;li&gt;Percentage change from a zero baseline is undefined, not automatically &lt;code&gt;0%&lt;/code&gt; or &lt;code&gt;100%&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Preserve signed values in the API when they are useful for logic.&lt;/li&gt;
&lt;li&gt;Presentation can use an absolute value when words already communicate direction.&lt;/li&gt;
&lt;li&gt;Object state is useful when one API response contains several related values.&lt;/li&gt;
&lt;li&gt;A small pure helper can turn numeric states into predictable user-facing language.&lt;/li&gt;
&lt;li&gt;Put insight fetching in the existing refresh flow if the metrics must update after CRUD mutations.&lt;/li&gt;
&lt;li&gt;Test rejection boundaries with deliberately awkward data, not only happy-path rows.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;200&lt;/code&gt; proves the endpoint responded; hand calculation helps prove the number.&lt;/li&gt;
&lt;li&gt;A passing happy path does not inspect dormant error branches.&lt;/li&gt;
&lt;li&gt;The repository outranks an article, handoff, roadmap, or memory when they disagree.&lt;/li&gt;
&lt;li&gt;A roadmap dependency is not permission to invent data.&lt;/li&gt;
&lt;li&gt;Commit at green checkpoints, subject plus why.&lt;/li&gt;
&lt;li&gt;Do not rewrite public Git history just to hide a typo.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next up: Phase 6 [make the data leave the app]
&lt;/h2&gt;

&lt;p&gt;The next roadmap phase is reporting and CSV export.&lt;/p&gt;

&lt;p&gt;The planned vertical path is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add an authenticated, owner-scoped report endpoint filtered by a date range.&lt;/li&gt;
&lt;li&gt;Verify that range manually.&lt;/li&gt;
&lt;li&gt;Generate CSV with Python's &lt;code&gt;csv&lt;/code&gt; module.&lt;/li&gt;
&lt;li&gt;Return it as a streaming response with download headers.&lt;/li&gt;
&lt;li&gt;Add an Export CSV button in React.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;But first: the narrow QA cleanup this article's source audit exposed.&lt;/p&gt;

&lt;p&gt;Because the app has learned to answer questions now.&lt;/p&gt;

&lt;p&gt;Before I teach it to export those answers, I want every error path to tell the truth too.&lt;/p&gt;

&lt;p&gt;The digital shoebox has become a dashboard.&lt;/p&gt;

&lt;p&gt;Next, it becomes a report.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>learning</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Phase 4 [Part 2] : The Deviation</title>
      <dc:creator>Azhar Alvi </dc:creator>
      <pubDate>Sun, 02 Aug 2026 15:05:28 +0000</pubDate>
      <link>https://dev.to/silentcarry/phase-4-part-2-the-deviation-144p</link>
      <guid>https://dev.to/silentcarry/phase-4-part-2-the-deviation-144p</guid>
      <description>&lt;p&gt;&lt;strong&gt;Kill the 30-minute dead-end where an expired token traps you on a broken screen, break one bloated component into smaller ones that each own their job, and give the login and app shell their first real coat of paint — the two debts I flagged at the end of Part 1, plus the refactor that made fixing them possible.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At the close of Part 1, my app finally had a &lt;em&gt;face&lt;/em&gt;: I could log in through a real form and do full CRUD without ever opening &lt;code&gt;/docs&lt;/code&gt;. Genuinely a milestone. But I ended that write-up with two honest confessions taped to the fridge. One: the login &lt;strong&gt;silently dies after 30 minutes&lt;/strong&gt; — my JWTs expire, and when they do, the app just shows a confusing error and strands you, still pretending you're logged in. Two: it looked like &lt;strong&gt;a 1998 government form&lt;/strong&gt;. A raw list, no styling, no polish.&lt;/p&gt;

&lt;p&gt;I said I'd deal with both in "Phase 5." I changed my mind again.&lt;/p&gt;

&lt;h2&gt;
  
  
  A second confession: I pulled two Phase-5 jobs forward [and picked up a third I didn't plan]
&lt;/h2&gt;

&lt;p&gt;Building a dashboard on top of a frontend that (a) traps users on a dead session, (b) crams everything into two god-components, and (c) is visually unusable felt like laying a nice floor over a cracked foundation. So before any new &lt;em&gt;features&lt;/em&gt;, I spent a session paying rent on the foundation.&lt;/p&gt;

&lt;p&gt;And here's the honest part: &lt;strong&gt;only one of these three jobs was on my list.&lt;/strong&gt; The expired-token fix, yes — I'd flagged it. But chasing that bug is what dragged the &lt;strong&gt;props refactor&lt;/strong&gt; in behind it, because the fix literally couldn't be written cleanly until I'd split my components up. One debt exposed another. That happens a lot, I'm learning.&lt;/p&gt;

&lt;p&gt;Let's call it &lt;strong&gt;PHASE 4, PART 2 — Paying the Rent:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reproduce the expired-token dead-end &lt;em&gt;on purpose&lt;/em&gt; [without waiting 30 minutes]&lt;/li&gt;
&lt;li&gt;Detect a &lt;code&gt;401&lt;/code&gt; on any request and force a clean re-login&lt;/li&gt;
&lt;li&gt;Learn &lt;strong&gt;props&lt;/strong&gt; for real — by passing a &lt;em&gt;function&lt;/em&gt; down so a child can trigger the parent's logout&lt;/li&gt;
&lt;li&gt;Split &lt;code&gt;App&lt;/code&gt; and &lt;code&gt;ExpenseList&lt;/code&gt; into &lt;code&gt;&amp;lt;LoginForm&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;AddExpenseForm&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Install &lt;strong&gt;Tailwind v4&lt;/strong&gt; [which is nothing like the tutorials you'll find]&lt;/li&gt;
&lt;li&gt;Style the login into a centered card and the app into a dashboard shell&lt;/li&gt;
&lt;li&gt;Make an &lt;em&gt;honest&lt;/em&gt; call about a lint warning instead of cargo-culting a "fix"&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Debt #1: the 30-minute dead-end [expired-token hardening]
&lt;/h2&gt;

&lt;p&gt;Here's the flaw, stated plainly. My login issues a JWT with a &lt;strong&gt;30-minute self-destruct&lt;/strong&gt; baked in [the &lt;code&gt;exp&lt;/code&gt; claim — Phase 3 me set that on purpose]. After 30 minutes the token is dead, and my FastAPI backend will reject &lt;em&gt;any&lt;/em&gt; authenticated request — list, add, edit, delete — with a &lt;code&gt;401 Unauthorized&lt;/code&gt;. That's not a bug; that's the security feature working exactly as designed.&lt;/p&gt;

&lt;p&gt;The bug was &lt;strong&gt;my reaction to it.&lt;/strong&gt; My &lt;code&gt;fetchExpenses&lt;/code&gt; caught &lt;em&gt;any&lt;/em&gt; bad response as a generic "Could not load expenses," and my mutation handlers just &lt;code&gt;console.error&lt;/code&gt;-ed the status. Meanwhile &lt;code&gt;App&lt;/code&gt; still had a truthy &lt;code&gt;token&lt;/code&gt; in state, so it kept rendering the logged-in view. The token was dead, but the app cheerfully pretended I was alive — an error message, a Logout button, and no automatic way out. A dead-end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First move, and this is the adversarial habit surviving without the GAN framing: I reproduced the failure on purpose instead of waiting half an hour.&lt;/strong&gt; DevTools → Application → Local Storage → double-click the &lt;code&gt;token&lt;/code&gt; value → jam a few junk characters onto the end → hit an endpoint. An invalid token gets the exact same &lt;code&gt;401&lt;/code&gt; an expired one would. Instant, repeatable bug. Watching it fail on demand is what made the fix obvious.&lt;/p&gt;

&lt;p&gt;Then I hit the wall that turned this into a bigger job than expected:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The logout logic lived in the wrong place to fix this.&lt;/strong&gt; My &lt;code&gt;handleLogout&lt;/code&gt; [&lt;code&gt;localStorage.removeItem('token')&lt;/code&gt; + &lt;code&gt;setToken(null)&lt;/code&gt;] sits in &lt;code&gt;App&lt;/code&gt;, the parent. But the &lt;code&gt;401&lt;/code&gt; &lt;em&gt;happens&lt;/em&gt; inside &lt;code&gt;ExpenseList&lt;/code&gt;, the child. A child component &lt;strong&gt;cannot&lt;/strong&gt; reach up and grab its parent's functions on its own. So how does the child tell the parent "the session is dead, log us out"? That question is what forced me to finally learn props — see the next section. The short version of the answer: the parent hands the child a &lt;em&gt;function&lt;/em&gt; to call.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;With that wired (details below), the fix inside each authed request was tiny and identical — check for &lt;code&gt;401&lt;/code&gt; &lt;strong&gt;first&lt;/strong&gt;, and if you see it, bail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;onAuthError&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// a function App passed down; it runs handleLogout&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #9 — a &lt;code&gt;401&lt;/code&gt; is a &lt;em&gt;subset&lt;/em&gt; of "not ok," so you have to catch it &lt;em&gt;before&lt;/em&gt; your generic error branch.&lt;/strong&gt; My old code did &lt;code&gt;if (!response.ok) throw ...&lt;/code&gt;. If I'd let that run first, every expired token would just show "Could not load expenses" and &lt;em&gt;never&lt;/em&gt; log me out. By checking &lt;code&gt;response.status === 401&lt;/code&gt; first and &lt;code&gt;return&lt;/code&gt;-ing, I short-circuit: &lt;code&gt;onAuthError()&lt;/code&gt; flips &lt;code&gt;token&lt;/code&gt; to &lt;code&gt;null&lt;/code&gt; up in &lt;code&gt;App&lt;/code&gt;, &lt;code&gt;App&lt;/code&gt; stops rendering &lt;code&gt;ExpenseList&lt;/code&gt;, and the login screen takes over. Order matters. Specific case before general case.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I dropped that same three-line guard into all four authed calls — &lt;code&gt;fetchExpenses&lt;/code&gt;, &lt;code&gt;handleAdd&lt;/code&gt;, &lt;code&gt;handleDelete&lt;/code&gt;, &lt;code&gt;handleUpdate&lt;/code&gt; — and while I was crawling through &lt;code&gt;handleDelete&lt;/code&gt;, I found a landmine:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #10 — a one-letter case typo that "works" only by luck.&lt;/strong&gt; My delete URL read &lt;code&gt;http://Localhost:8000&lt;/code&gt; — capital L. It had been working the whole time because &lt;strong&gt;Windows treats hostnames case-insensitively&lt;/strong&gt;, so &lt;code&gt;Localhost&lt;/code&gt; resolves fine on my machine. The moment I deploy the backend to a Linux box [Phase 9], that could bite. Fixed it to lowercase &lt;code&gt;localhost&lt;/code&gt; now, while I was in the neighborhood. Lesson: "it works on my machine" and "it's correct" are not the same sentence.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;The payoff:&lt;/strong&gt; I re-ran my sabotage — logged in, corrupted the token, triggered a request — and instead of the stuck error screen, the app &lt;strong&gt;bounced me straight back to the login form.&lt;/strong&gt; Tested it from a reload &lt;em&gt;and&lt;/em&gt; from the add button, to prove the guard fires from more than one place. The session can no longer lie to me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cleanup I didn't plan on: props [the refactor]
&lt;/h2&gt;

&lt;p&gt;Remember the wall above — the child couldn't reach the parent's logout. The clean answer to that is the single most important React concept I'd been avoiding: &lt;strong&gt;props.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Props are how a parent hands things &lt;em&gt;down&lt;/em&gt; to a child&lt;/strong&gt; — data, config, or [the part that unlocks everything] &lt;strong&gt;functions.&lt;/strong&gt; And the mirror image is the pattern that fixed my bug: &lt;strong&gt;a child reports an event &lt;em&gt;up&lt;/em&gt; by calling a function its parent passed down.&lt;/strong&gt; That's it. Data flows down; events flow up as function calls. My &lt;code&gt;onAuthError&lt;/code&gt; was my very first one — &lt;code&gt;App&lt;/code&gt; handed &lt;code&gt;ExpenseList&lt;/code&gt; its &lt;code&gt;handleLogout&lt;/code&gt;, renamed &lt;code&gt;onAuthError&lt;/code&gt; for clarity, and the child calls it when it smells a dead session.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In App: hand the function down&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ExpenseList&lt;/span&gt; &lt;span class="na"&gt;onAuthError&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleLogout&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In ExpenseList: receive it&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ExpenseList&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;onAuthError&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;{ onAuthError }&lt;/code&gt; in the function signature stopped me for a second, so I'll pin down what it is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;{ }&lt;/code&gt; in a component's arguments is &lt;em&gt;object destructuring&lt;/em&gt; — the cousin of the &lt;code&gt;[ ]&lt;/code&gt; I already knew from &lt;code&gt;useState&lt;/code&gt;.&lt;/strong&gt; React always calls my component with &lt;strong&gt;one&lt;/strong&gt; argument: a single object holding all its props. &lt;code&gt;{ onAuthError }&lt;/code&gt; reaches into that object and pulls out the &lt;code&gt;onAuthError&lt;/code&gt; key by &lt;em&gt;name&lt;/em&gt;. Contrast: &lt;code&gt;useState&lt;/code&gt; gives me &lt;code&gt;const [value, setter]&lt;/code&gt; — array destructuring, which unpacks &lt;strong&gt;by position&lt;/strong&gt; [that's why order matters there]. Objects unpack &lt;strong&gt;by name&lt;/strong&gt;, so the name inside the braces must exactly match the prop I passed. A typo like &lt;code&gt;{ onAuthErr }&lt;/code&gt; just silently hands me &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once I understood props, the mess I'd been living in became obvious: my &lt;strong&gt;login form&lt;/strong&gt; was welded inside &lt;code&gt;App&lt;/code&gt;, and my &lt;strong&gt;add-expense form&lt;/strong&gt; was welded inside &lt;code&gt;ExpenseList&lt;/code&gt;. Two components doing three jobs each. So I split them out, and I picked a deliberate design rule for both:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Each form owns its &lt;em&gt;own&lt;/em&gt; state and does its &lt;em&gt;own&lt;/em&gt; API call, then reports the result up through a callback prop. The parent stays thin and owns only what's genuinely shared.&lt;/strong&gt; &lt;code&gt;App&lt;/code&gt; doesn't care about my email or password keystrokes — it only cares "did we get a token?" So &lt;code&gt;LoginForm&lt;/code&gt; keeps its own &lt;code&gt;email&lt;/code&gt;/&lt;code&gt;password&lt;/code&gt; state, does the login &lt;code&gt;fetch&lt;/code&gt; itself, and calls &lt;code&gt;onLoggedIn(token)&lt;/code&gt; on success. This is &lt;em&gt;colocation&lt;/em&gt;: keep state as close as possible to where it's used, and lift up only the thing that's actually shared [the token].&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;LoginForm&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;onLoggedIn&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setEmail&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setPassword&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleSubmit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;formBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;formBody&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;username&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;formBody&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;password&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://localhost:8000/auth/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/x-www-form-urlencoded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;formBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nf"&gt;onLoggedIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// report success UP to App&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Login Failed:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...the form JSX&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;App&lt;/code&gt; shed all the login state and shrank to a coordinator — it now owns only the &lt;code&gt;token&lt;/code&gt; plus two tiny handlers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleLoginSuccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// the side-effect stays with the owner of the token&lt;/span&gt;
  &lt;span class="nf"&gt;setToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleLogout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;AddExpenseForm&amp;gt;&lt;/code&gt; came out the same way, except it needs &lt;strong&gt;two&lt;/strong&gt; callbacks: &lt;code&gt;onAdded&lt;/code&gt; [it succeeded — parent, please re-fetch the list] and &lt;code&gt;onAuthError&lt;/code&gt; [the 401 guard]. And that surfaced a nice, normal wrinkle:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Prop-forwarding is fine and expected.&lt;/strong&gt; &lt;code&gt;ExpenseList&lt;/code&gt; receives &lt;code&gt;onAuthError&lt;/code&gt; from &lt;code&gt;App&lt;/code&gt;, and then passes it &lt;em&gt;one level deeper&lt;/em&gt; to &lt;code&gt;AddExpenseForm&lt;/code&gt;. A prop can be handed down through multiple layers. &lt;code&gt;&amp;lt;AddExpenseForm onAdded={fetchExpenses} onAuthError={onAuthError} /&amp;gt;&lt;/code&gt; — notice &lt;code&gt;onAdded={fetchExpenses}&lt;/code&gt; is me passing &lt;code&gt;ExpenseList&lt;/code&gt;'s own re-fetch function down so the child can trigger a refresh after a successful add. Same function, reused through a prop.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two more real things fell out of the refactor:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #11 — moving JSX into a child leaves &lt;em&gt;leftovers&lt;/em&gt; in the parent.&lt;/strong&gt; The instant I extracted &lt;code&gt;&amp;lt;LoginForm&amp;gt;&lt;/code&gt;, my login screen showed the &lt;strong&gt;title twice&lt;/strong&gt;. Cause: I'd put the &lt;code&gt;&amp;lt;h1&amp;gt;Smart Expense Manager&amp;lt;/h1&amp;gt;&lt;/code&gt; inside &lt;code&gt;LoginForm&lt;/code&gt;, but &lt;code&gt;App&lt;/code&gt; was &lt;em&gt;still&lt;/em&gt; rendering its own old heading right above it. This is the exact same lesson as the stray-render leak from Part 1 — &lt;strong&gt;when you restructure, hunt the leftovers.&lt;/strong&gt; The fix doubled as a design decision: the app title is &lt;em&gt;shell&lt;/em&gt; UI, so it belongs in &lt;code&gt;App&lt;/code&gt;, exactly once. &lt;code&gt;LoginForm&lt;/code&gt; renders only the form.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha #12 — the tempting lint "fix" is an infinite-loop trap.&lt;/strong&gt; ESLint started nagging: &lt;em&gt;"React Hook useEffect has a missing dependency: 'fetchExpenses'."&lt;/em&gt; The obvious move is to obey it and write &lt;code&gt;}, [fetchExpenses])&lt;/code&gt;. &lt;strong&gt;Don't.&lt;/strong&gt; &lt;code&gt;fetchExpenses&lt;/code&gt; is a plain function, so React creates a &lt;em&gt;brand-new copy of it every render&lt;/em&gt;. Put it in the dependency array and the effect thinks its dependency changed on every render → it re-fetches → &lt;code&gt;setState&lt;/code&gt; re-renders → new function copy → fetches again → &lt;strong&gt;infinite loop.&lt;/strong&gt; My empty &lt;code&gt;[]&lt;/code&gt; is actually the behavior I want [run once on mount]. So I made a real decision instead of cargo-culting: I &lt;strong&gt;left the warning alone.&lt;/strong&gt; It's a &lt;em&gt;warning&lt;/em&gt;, not an error, and the code is correct. I didn't even add an &lt;code&gt;eslint-disable&lt;/code&gt; comment — that would just be decorating a non-problem. &lt;code&gt;[The genuinely-correct fix is wrapping the function in&lt;/code&gt;&lt;code&gt;useCallback&lt;/code&gt;&lt;code&gt;so its identity is stable — but that cascades into memoizing the callbacks I pass down too, which is more machinery than a beginner project needs right now. useCallback = "know this exists."]&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The whole app behaves &lt;em&gt;identically&lt;/em&gt; after all this — same login, same CRUD, same 401 bounce. But &lt;code&gt;App&lt;/code&gt; and &lt;code&gt;ExpenseList&lt;/code&gt; are now thin coordinators, and each form is a small thing that owns its one job. That's the entire point of the refactor: no new features, just a codebase I can actually keep building on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debt #2: it looked like a 1998 government form [Tailwind]
&lt;/h2&gt;

&lt;p&gt;Time to make it not-ugly. I chose &lt;strong&gt;Tailwind CSS&lt;/strong&gt; [utility-first styling] and a &lt;strong&gt;modern SaaS-dashboard&lt;/strong&gt; look — soft-gray canvas, white cards with subtle shadows, a top header bar. Partly because it's everywhere in industry, partly because that card-and-shell layout is a direct warm-up for the Phase 5 dashboard.&lt;/p&gt;

&lt;p&gt;First lesson before I typed a single class:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #13 — Tailwind v4 threw out the setup every tutorial still teaches.&lt;/strong&gt; Search "install Tailwind" and you'll get v3 instructions: a &lt;code&gt;tailwind.config.js&lt;/code&gt;, a &lt;code&gt;content: [...]&lt;/code&gt; array, &lt;code&gt;@tailwind base/components/utilities&lt;/code&gt; directives, PostCSS wiring. &lt;strong&gt;In v4, all of that is gone.&lt;/strong&gt; It's now a Vite plugin plus a &lt;em&gt;single&lt;/em&gt; line of CSS. I actually verified the current steps against today's docs instead of trusting my memory or an old blog — which is a habit I want to keep for fast-moving tools, because copying stale setup is a guaranteed hour lost.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The entire v4 setup, for a Vite + React app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;tailwindcss @tailwindcss/vite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.js — ADD the plugin, keep react()&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;react&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@vitejs/plugin-react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;tailwindcss&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tailwindcss/vite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;react&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;tailwindcss&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* src/index.css — this ONE line replaces all the old @tailwind directives */&lt;/span&gt;
&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="s1"&gt;"tailwindcss"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A restart of the dev server later [config changes don't hot-reload — one to remember], I dropped &lt;code&gt;className="text-3xl font-bold text-blue-600"&lt;/code&gt; on my title, watched it go big and blue, and knew the pipe was live. Note it's &lt;strong&gt;&lt;code&gt;className&lt;/code&gt;, not &lt;code&gt;class&lt;/code&gt;&lt;/strong&gt; — the same JSX rule that bit me back in Part 1, now paying dividends because &lt;em&gt;every&lt;/em&gt; Tailwind style rides on &lt;code&gt;className&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The mental model that made Tailwind click:&lt;/strong&gt; you don't write CSS rules in a separate file. You &lt;strong&gt;compose a look out of tiny, single-purpose utility classes right on the element&lt;/strong&gt; — &lt;code&gt;p-8&lt;/code&gt; is padding, &lt;code&gt;rounded-xl&lt;/code&gt; is corners, &lt;code&gt;shadow-md&lt;/code&gt; is a shadow, &lt;code&gt;bg-white&lt;/code&gt; is the background. Honest reaction: the &lt;code&gt;className&lt;/code&gt; strings get &lt;em&gt;long&lt;/em&gt; and repetitive, and my two login inputs share an identical one. That felt wrong at first, but it's genuinely idiomatic Tailwind. &lt;code&gt;[Later I can collapse a repeated set with&lt;/code&gt;&lt;code&gt;@apply&lt;/code&gt;&lt;code&gt;in CSS, or extract a tiny&lt;/code&gt;&lt;code&gt;&amp;lt;Input&amp;gt;&lt;/code&gt;&lt;code&gt;component. Know this exists; not now.]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Here's the login screen — a centered white card floating on a gray page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// App's logged-out branch&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"min-h-screen bg-slate-100 flex items-center justify-center"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"w-full max-w-sm bg-white rounded-xl shadow-md p-8 flex flex-col gap-6"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-2xl font-bold text-slate-800 text-center"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Smart Expense Manager&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LoginForm&lt;/span&gt; &lt;span class="na"&gt;onLoggedIn&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleLoginSuccess&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The handful of utilities I'll actually reuse forever:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;flex items-center justify-center&lt;/code&gt;&lt;/strong&gt; — &lt;em&gt;the&lt;/em&gt; centering combo. &lt;code&gt;items-center&lt;/code&gt; centers vertically, &lt;code&gt;justify-center&lt;/code&gt; horizontally. Wrap it in &lt;code&gt;min-h-screen&lt;/code&gt; so there's a full viewport to center &lt;em&gt;within&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;flex flex-col gap-*&lt;/code&gt;&lt;/strong&gt; — stack children in a column with even spacing between them, no margins to hand-tune.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;focus:ring-2&lt;/code&gt; / &lt;code&gt;hover:bg-blue-700&lt;/code&gt;&lt;/strong&gt; — the &lt;code&gt;focus:&lt;/code&gt; and &lt;code&gt;hover:&lt;/code&gt; prefixes apply a style only in that &lt;em&gt;state&lt;/em&gt;. It's Tailwind's answer to CSS pseudo-classes, and it's genuinely elegant — my inputs get a blue focus ring, my button darkens on hover, all inline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then the logged-in shell — a real SaaS layout with a header bar and a centered content column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"min-h-screen bg-slate-100"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;header&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"bg-white shadow-sm"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"max-w-3xl mx-auto px-6 py-4 flex items-center justify-between"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-xl font-bold text-slate-800"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Smart Expense Manager&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleLogout&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-sm text-slate-600 border border-slate-300 rounded-md px-3 py-1.5 hover:bg-slate-50 transition-colors"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        Logout
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;header&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"max-w-3xl mx-auto px-6 py-8"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ExpenseList&lt;/span&gt; &lt;span class="na"&gt;onAuthError&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleLogout&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth banking here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;&amp;lt;header&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; instead of &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;s.&lt;/strong&gt; These are &lt;em&gt;semantic&lt;/em&gt; HTML — they render identically but tell browsers and screen readers what each region &lt;em&gt;is&lt;/em&gt;. A free accessibility and clarity win; a small habit that separates "works" from "professional."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;max-w-3xl mx-auto&lt;/code&gt;&lt;/strong&gt; is the centered-column pattern. &lt;code&gt;max-w-3xl&lt;/code&gt; caps the width so content isn't a painfully wide ribbon on a big monitor; &lt;code&gt;mx-auto&lt;/code&gt; sets left/right margins to auto, which centers the block. I used the &lt;em&gt;same&lt;/em&gt; &lt;code&gt;max-w-3xl mx-auto px-6&lt;/code&gt; on both the header's inner div and &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt;, which is what keeps the logo and the content aligned down the page. &lt;code&gt;justify-between&lt;/code&gt; in the header shoves the title hard-left and Logout hard-right.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one small stumble that taught the right instinct:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #14 — reach for &lt;code&gt;gap&lt;/code&gt; on the parent, not a margin on each child.&lt;/strong&gt; My title and the email box were jammed together with no space. My first instinct was to slap a &lt;code&gt;margin-bottom&lt;/code&gt; on the heading. The cleaner fix — and the more Tailwind-idiomatic one — was to make the &lt;em&gt;card&lt;/em&gt; a &lt;code&gt;flex flex-col gap-6&lt;/code&gt; container, so the gap spaces &lt;strong&gt;every&lt;/strong&gt; child uniformly [title → form] with one declaration on the parent. Set the rhythm once, up top, instead of hand-tuning margins on each element and fighting margin quirks.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Honest status: this is the pass I've &lt;em&gt;started&lt;/em&gt;, not finished.&lt;/strong&gt; The login card and the whole app shell are dressed and look legitimately decent. What's &lt;em&gt;not&lt;/em&gt; done yet: the expense-list itself is still raw inside that nice shell — the list needs to become a card, the add-expense form needs the same input/button styling as login, and each row needs tidy edit/delete buttons and clean loading/empty/error states. That's the frontier, and it flows directly into Phase 5.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of every React conversation [the model that tied it together]
&lt;/h2&gt;

&lt;p&gt;If Part 1's big mental model was "the Save button doesn't save — it &lt;em&gt;asks the backend to&lt;/em&gt;," Part 2's is smaller but just as clarifying:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data flows &lt;em&gt;down&lt;/em&gt;, events flow &lt;em&gt;up&lt;/em&gt;, and only a state change repaints the screen.&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A parent passes &lt;strong&gt;data or functions down&lt;/strong&gt; to a child as &lt;strong&gt;props&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;When something happens in the child [login succeeded, a &lt;code&gt;401&lt;/code&gt; came back, an expense was added], the child &lt;strong&gt;calls a function the parent gave it&lt;/strong&gt; — that's the event flowing back up.&lt;/li&gt;
&lt;li&gt;That function, running in the parent, &lt;strong&gt;changes state&lt;/strong&gt; [&lt;code&gt;setToken(...)&lt;/code&gt;]. And state changing is the &lt;em&gt;only&lt;/em&gt; thing that makes React re-render and swap the screen.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every single thing I built this session is that loop. &lt;code&gt;LoginForm&lt;/code&gt; → &lt;code&gt;onLoggedIn(token)&lt;/code&gt; → &lt;code&gt;setToken&lt;/code&gt; → the UI flips to logged-in. &lt;code&gt;ExpenseList&lt;/code&gt; → &lt;code&gt;onAuthError()&lt;/code&gt; → &lt;code&gt;setToken(null)&lt;/code&gt; → the UI flips back to login. &lt;code&gt;AddExpenseForm&lt;/code&gt; → &lt;code&gt;onAdded()&lt;/code&gt; → re-fetch → the list re-renders. Once I saw the one shape, the whole component tree stopped being mysterious.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stuff I want to remember [the honest takeaways]
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A JWT expiring is the security feature working, not a bug&lt;/strong&gt; — but &lt;em&gt;not handling&lt;/em&gt; the resulting &lt;code&gt;401&lt;/code&gt; is the bug. Detect it and force a clean re-login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reproduce a time-based bug on purpose&lt;/strong&gt; — corrupt the token in DevTools instead of waiting 30 minutes. An invalid token gives the same &lt;code&gt;401&lt;/code&gt; as an expired one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the specific status [&lt;code&gt;401&lt;/code&gt;] &lt;em&gt;before&lt;/em&gt; the generic &lt;code&gt;!response.ok&lt;/code&gt; branch,&lt;/strong&gt; or the general case masks the special one. Specific before general.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Props are how a parent passes data — and functions — down; a child reports events up by calling a function the parent gave it.&lt;/strong&gt; Data down, events up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;{ prop }&lt;/code&gt; in a component signature is object destructuring [by name]; &lt;code&gt;useState&lt;/code&gt;'s &lt;code&gt;[value, setter]&lt;/code&gt; is array destructuring [by position].&lt;/strong&gt; Same idea, different brackets — and the prop name must match exactly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Colocate state:&lt;/strong&gt; each form owns its own state and its own API call; the parent stays thin and owns only what's shared [the token]. Lift up only the shared thing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prop-forwarding is normal&lt;/strong&gt; — a prop can be handed down through several layers [&lt;code&gt;App&lt;/code&gt; → &lt;code&gt;ExpenseList&lt;/code&gt; → &lt;code&gt;AddExpenseForm&lt;/code&gt;].&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When you restructure, hunt the leftovers&lt;/strong&gt; — moving JSX into a child left a duplicated &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; behind in the parent. [Same lesson as Part 1's stray render. It keeps coming back.]&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't cargo-cult a lint "fix."&lt;/strong&gt; Obeying the &lt;code&gt;exhaustive-deps&lt;/code&gt; warning naively [&lt;code&gt;[fetchExpenses]&lt;/code&gt;] creates an infinite fetch loop, because the function is recreated every render. The empty &lt;code&gt;[]&lt;/code&gt; was correct. A warning is not an error. &lt;code&gt;[Real fix = useCallback; deferred.]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind v4 is nothing like the v3 tutorials&lt;/strong&gt; — no config file, no &lt;code&gt;content&lt;/code&gt; array, no &lt;code&gt;@tailwind&lt;/code&gt; directives. Just the &lt;code&gt;@tailwindcss/vite&lt;/code&gt; plugin + &lt;code&gt;@import "tailwindcss";&lt;/code&gt;. Verify current docs for fast-moving tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind is utility-first:&lt;/strong&gt; compose a look from tiny classes on &lt;code&gt;className&lt;/code&gt; [still not &lt;code&gt;class&lt;/code&gt;]. Long, repeated class strings are normal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;flex items-center justify-center&lt;/code&gt;&lt;/strong&gt; centers; &lt;strong&gt;&lt;code&gt;flex flex-col gap-*&lt;/code&gt;&lt;/strong&gt; spaces stacked children [set spacing on the parent, not margins on each child]; &lt;strong&gt;&lt;code&gt;focus:&lt;/code&gt;/&lt;code&gt;hover:&lt;/code&gt;&lt;/strong&gt; prefixes are Tailwind's pseudo-classes; &lt;strong&gt;&lt;code&gt;max-w-* mx-auto&lt;/code&gt;&lt;/strong&gt; is the centered column.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use semantic &lt;code&gt;&amp;lt;header&amp;gt;&lt;/code&gt;/&lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt;&lt;/strong&gt; instead of &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; soup — free clarity and accessibility.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The one React loop:&lt;/strong&gt; data down (props) → event up (callback) → &lt;code&gt;setState&lt;/code&gt; → re-render. Everything I built is that shape.&lt;/li&gt;
&lt;li&gt;Commit at every green checkpoint, subject + why. I committed after the 401 fix, after each extraction, and after the Tailwind install — small and often.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next up: Phase 5, for real this time. The face now has a &lt;strong&gt;pulse&lt;/strong&gt; [the session no longer dead-ends], its internals are &lt;strong&gt;organized&lt;/strong&gt; [small components that each own their job], and it's wearing its &lt;strong&gt;first real clothes&lt;/strong&gt; [a login card and a dashboard shell]. What's left is to finish dressing the expense list, and then give the app some actual &lt;em&gt;expression&lt;/em&gt;: a dashboard with spending insights — totals, spend-by-category, month-over-month — turning "it works and it's tidy" into "I'd genuinely use this." The foundation's finally solid enough to build something interesting on top of. See you there.&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>frontend</category>
      <category>softwaredevelopment</category>
      <category>webdev</category>
    </item>
    <item>
      <title>PHASE 4 — The Face</title>
      <dc:creator>Azhar Alvi </dc:creator>
      <pubDate>Sat, 01 Aug 2026 09:29:14 +0000</pubDate>
      <link>https://dev.to/silentcarry/phase-4-the-face-5b25</link>
      <guid>https://dev.to/silentcarry/phase-4-the-face-5b25</guid>
      <description>&lt;p&gt;&lt;strong&gt;Scaffold a React app, log in through a real form, and do full CRUD without ever opening &lt;code&gt;/docs&lt;/code&gt; again — then close the three gaps nobody builds: loading, empty, and error.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So by the end of Phase 3 my app had a memory [a database], a mouth [HTTP endpoints], and a lock on the door [auth]. It knew who you were and refused to show you anyone else's expenses. Genuinely satisfying. There was just one small, embarrassing detail: &lt;strong&gt;the only human who could actually use it was me, poking at &lt;code&gt;/docs&lt;/code&gt;.&lt;/strong&gt; My "app" was a Swagger page and a lot of faith. No screen, no login box, nothing a normal person could look at. It had everything except a &lt;em&gt;face&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Phase 4 gives it one.&lt;/p&gt;

&lt;h2&gt;
  
  
  A confession before we start: I changed the plan
&lt;/h2&gt;

&lt;p&gt;If you read the end of my Phase 3 write-up, I promised Phase 4 would be filtering, sorting, and pagination — making the backend list &lt;em&gt;usable&lt;/em&gt; at scale. I changed my mind. I went with frontend.&lt;/p&gt;

&lt;p&gt;My reasoning, honestly: I've been living in Python for four phases and I've &lt;em&gt;never&lt;/em&gt; touched React. Building the UI now forces me to re-meet my own auth, tokens, CORS, and endpoints from the &lt;strong&gt;client's&lt;/strong&gt; side of the wire — which is the best way to find out whether I actually understood them. The known cost, which I'm accepting on purpose: &lt;code&gt;GET /expenses&lt;/code&gt; is still unbounded, so when pagination lands later I'll have to come back and revise this list UI. Flagged, accepted, moving on.&lt;/p&gt;

&lt;p&gt;Let's call it &lt;strong&gt;PHASE 4 — The Face:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scaffold a real React app with Vite&lt;/li&gt;
&lt;li&gt;Write my first component and actually understand JSX&lt;/li&gt;
&lt;li&gt;Build a login form that holds its own text [controlled inputs + &lt;code&gt;useState&lt;/code&gt;]&lt;/li&gt;
&lt;li&gt;Call the backend from the browser — and get mugged by CORS&lt;/li&gt;
&lt;li&gt;Store the token, and reckon with &lt;em&gt;where&lt;/em&gt; to store it&lt;/li&gt;
&lt;li&gt;Fetch and render the expense list [&lt;code&gt;useEffect&lt;/code&gt;]&lt;/li&gt;
&lt;li&gt;Close the three gaps: loading, empty, error&lt;/li&gt;
&lt;li&gt;Wire up add / delete / edit — full CRUD from the UI&lt;/li&gt;
&lt;li&gt;Fix the bug that made login "do nothing until I refreshed"&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  First, the new world [everything here is different from the backend]
&lt;/h2&gt;

&lt;p&gt;The backend runs on Python; you install libraries with &lt;code&gt;pip&lt;/code&gt; and freeze them into &lt;code&gt;requirements.txt&lt;/code&gt;. The frontend has an exact parallel universe, and learning the mapping made the whole thing click:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Node.js&lt;/strong&gt; is the JavaScript runtime — the "Python interpreter" of the frontend. [I'm on v22.]&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;npm&lt;/strong&gt; is Node's package manager — this is &lt;code&gt;pip&lt;/code&gt;. [I'm on v11.]&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;package.json&lt;/code&gt;&lt;/strong&gt; is &lt;code&gt;requirements.txt&lt;/code&gt;'s counterpart — &lt;em&gt;except it updates itself automatically when you install&lt;/em&gt;. No manual &lt;code&gt;freeze&lt;/code&gt; step over here. One less habit to remember on this side of the fence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;node_modules/&lt;/code&gt;&lt;/strong&gt; is &lt;code&gt;venv/&lt;/code&gt; — enormous, machine-specific, and it must &lt;strong&gt;never&lt;/strong&gt; touch git.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vite&lt;/strong&gt; [French for "quick," said &lt;em&gt;veet&lt;/em&gt;] is the tool that scaffolds the app and runs the hot-reloading dev server. It's the frontend's &lt;code&gt;uvicorn --reload&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two forks in the road, two flagged shortcuts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vite offered me &lt;strong&gt;TypeScript or plain JavaScript&lt;/strong&gt;. I took plain JS. TypeScript is fantastic and production-common, but it's a &lt;em&gt;second&lt;/em&gt; new language stacked on top of React, and one new language at a time is plenty. &lt;code&gt;[TypeScript = "know this exists," not now.]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;It also asked which linter — &lt;strong&gt;Oxlint or ESLint&lt;/strong&gt;. [A linter is a spell-checker for code: it reads your code without running it and flags likely bugs and sloppy style.] I picked &lt;strong&gt;ESLint&lt;/strong&gt; because it's the industry default — when I hit an error and google it, ESLint results are everywhere. Oxlint is newer and faster but less documented. &lt;code&gt;[Oxlint = know this exists.]&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm create vite@latest frontend &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;--template&lt;/span&gt; react
&lt;span class="nb"&gt;cd &lt;/span&gt;frontend
npm &lt;span class="nb"&gt;install
&lt;/span&gt;npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last command lit up a starter page at &lt;code&gt;http://localhost:5173&lt;/code&gt; with a clicky "count is 0" button — which, I later learned, is &lt;code&gt;useState&lt;/code&gt; doing its thing. My first commit of the phase was that untouched scaffold, on its own: a clean "React works" checkpoint I could always fall back to before I started breaking things.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Habit that carried over:&lt;/strong&gt; before committing, I checked git wasn't about to swallow &lt;code&gt;node_modules&lt;/code&gt;. Turns out Vite drops its &lt;em&gt;own&lt;/em&gt; &lt;code&gt;.gitignore&lt;/code&gt; inside &lt;code&gt;frontend/&lt;/code&gt; — and yes, git supports &lt;strong&gt;multiple&lt;/strong&gt; &lt;code&gt;.gitignore&lt;/code&gt; files, one per folder. I didn't take that on faith [never, with git]; I ran &lt;code&gt;git check-ignore frontend/node_modules&lt;/code&gt; and it echoed the path back, which is git saying "yep, ignoring that." Verify, don't assume.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 1: My first component [JSX has three rules that bite]
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;React component&lt;/strong&gt; is deceptively simple: it's just a &lt;strong&gt;JavaScript function, with a Capitalized name, that returns markup.&lt;/strong&gt; That markup is &lt;strong&gt;JSX&lt;/strong&gt; — it looks like HTML but it's actually JavaScript wearing a costume. I overwrote Vite's demo &lt;code&gt;App.jsx&lt;/code&gt; with my own [safe: it's boilerplate, and I'd just committed, so &lt;code&gt;git checkout&lt;/code&gt; would bring it back instantly]:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;App&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Smart Expense Manager&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Frontend is alive.&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;App&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three JSX rules that trip &lt;em&gt;everyone&lt;/em&gt;, now filed away:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One single root element.&lt;/strong&gt; You can't return two side-by-side tags; wrap them in a parent. [React will yell at you.]&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;className&lt;/code&gt;, not &lt;code&gt;class&lt;/code&gt;&lt;/strong&gt; — because &lt;code&gt;class&lt;/code&gt; is a reserved word in JavaScript.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;{ }&lt;/code&gt; to drop a JS value into markup&lt;/strong&gt; — &lt;code&gt;{2 + 2}&lt;/code&gt; renders &lt;code&gt;4&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;export default App&lt;/code&gt; is what lets &lt;code&gt;main.jsx&lt;/code&gt; do &lt;code&gt;import App from './App.jsx'&lt;/code&gt; — the JS version of exposing a name so another module can import it. I saved the file, and the browser &lt;em&gt;instantly&lt;/em&gt; swapped to my text without a refresh. That's &lt;strong&gt;hot reload&lt;/strong&gt;, and it's addictive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: A login form that holds its own text [useState]
&lt;/h2&gt;

&lt;p&gt;My instinct was to store what the user types in a normal variable — &lt;code&gt;let email = ''&lt;/code&gt;. &lt;strong&gt;Wrong, and here's the precise why:&lt;/strong&gt; React only updates the screen when it &lt;strong&gt;re-renders&lt;/strong&gt;, and a plain variable changing doesn't trigger one. The box would go stale.&lt;/p&gt;

&lt;p&gt;The fix is &lt;strong&gt;&lt;code&gt;useState&lt;/code&gt;&lt;/strong&gt;, a &lt;em&gt;hook&lt;/em&gt; — a special function that lets a component remember a value across re-renders &lt;em&gt;and&lt;/em&gt; re-render when that value changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;App&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setEmail&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setPassword&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleSubmit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Logging in with:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Smart Expense Manager&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;onSubmit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleSubmit&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt;
          &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt;
          &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Email"&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt;
          &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"password"&lt;/span&gt;
          &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Password"&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setPassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Log in&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New ideas, in plain language:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;const [email, setEmail] = useState('')&lt;/code&gt;&lt;/strong&gt; returns a &lt;em&gt;pair&lt;/em&gt; — the current value and a setter. That &lt;code&gt;[...]&lt;/code&gt; unpacking is &lt;strong&gt;array destructuring&lt;/strong&gt;. The iron rule: &lt;strong&gt;never reassign &lt;code&gt;email&lt;/code&gt; directly — always call &lt;code&gt;setEmail(...)&lt;/code&gt;.&lt;/strong&gt; The setter is what tells React "this changed, re-render."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Controlled input&lt;/strong&gt; — the two-way loop that &lt;em&gt;is&lt;/em&gt; the React form pattern: &lt;code&gt;value={email}&lt;/code&gt; makes the box always display state, &lt;code&gt;onChange&lt;/code&gt; pushes every keystroke back into state. State is the single source of truth; the input just mirrors it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;import { useState }&lt;/code&gt;&lt;/strong&gt; with braces is a &lt;em&gt;named&lt;/em&gt; import; &lt;code&gt;import App&lt;/code&gt; without braces is a &lt;em&gt;default&lt;/em&gt; import. The braces are the tell.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the one that will get me someday:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #1 — &lt;code&gt;event.preventDefault()&lt;/code&gt; is not optional.&lt;/strong&gt; By default, submitting an HTML form makes the browser &lt;strong&gt;reload the entire page&lt;/strong&gt; [a genuine 1990s behavior]. In a React app that wipes all your state and blanks the screen. &lt;code&gt;preventDefault()&lt;/code&gt; cancels it so &lt;em&gt;I&lt;/em&gt; stay in control. I know for a fact I'll forget this line one day and watch my page flash — but now I'll know exactly why.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I opened DevTools, typed in both boxes [text appeared as I typed — the loop works], clicked Log in, and watched &lt;code&gt;Logging in with: ...&lt;/code&gt; print to the console with no page reload. First real interaction, done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Call the backend — and get mugged by CORS
&lt;/h2&gt;

&lt;p&gt;Now the browser had to actually &lt;em&gt;talk&lt;/em&gt; to FastAPI. Two new concepts collided here.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;&lt;code&gt;async&lt;/code&gt; / &lt;code&gt;await&lt;/code&gt; and &lt;code&gt;fetch&lt;/code&gt;.&lt;/strong&gt; Network calls take time, and JavaScript can't freeze the browser waiting. So &lt;code&gt;fetch&lt;/code&gt; [the browser's built-in HTTP client — the JS version of Python's &lt;code&gt;requests&lt;/code&gt;] returns a &lt;strong&gt;Promise&lt;/strong&gt;, an "I'll have your answer later" placeholder. &lt;strong&gt;&lt;code&gt;await&lt;/code&gt;&lt;/strong&gt; pauses &lt;em&gt;this function&lt;/em&gt; until it resolves, and any function using &lt;code&gt;await&lt;/code&gt; must be marked &lt;strong&gt;&lt;code&gt;async&lt;/code&gt;&lt;/strong&gt; [same word as FastAPI's &lt;code&gt;async def&lt;/code&gt;].&lt;/p&gt;

&lt;p&gt;Second — and this is the single biggest gotcha of the whole phase:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #2 — the login endpoint is the odd one out: it wants FORM data, not JSON.&lt;/strong&gt; My &lt;code&gt;/auth/login&lt;/code&gt; uses &lt;code&gt;OAuth2PasswordRequestForm&lt;/code&gt;, which reads &lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt;, &lt;strong&gt;not&lt;/strong&gt; JSON. Send it JSON and you get a baffling &lt;code&gt;422&lt;/code&gt;. So I built the body with &lt;code&gt;URLSearchParams&lt;/code&gt; and set the matching &lt;code&gt;Content-Type&lt;/code&gt;. Bank this: &lt;strong&gt;the OAuth2 login endpoint is form-encoded; every &lt;em&gt;other&lt;/em&gt; endpoint in my API speaks JSON.&lt;/strong&gt; Mixing these two up is &lt;em&gt;the&lt;/em&gt; classic source of 422s.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleSubmit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;formBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;formBody&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;username&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// OAuth2 calls the field "username" — I put my email there&lt;/span&gt;
  &lt;span class="nx"&gt;formBody&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;password&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:8000/auth/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/x-www-form-urlencoded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;formBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Status:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Body:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My mentor &lt;em&gt;predicted&lt;/em&gt; this would fail, and it did — with a big red wall:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #3 — CORS, the thing my roadmap warned trips up everyone.&lt;/strong&gt; &lt;code&gt;Access to fetch at 'http://localhost:8000/auth/login' from origin 'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header...&lt;/code&gt; My frontend runs on port &lt;strong&gt;5173&lt;/strong&gt;, my backend on &lt;strong&gt;8000&lt;/strong&gt; — different port means the browser considers them &lt;strong&gt;different origins&lt;/strong&gt;, and it refused to let my code read the response.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;CORS [Cross-Origin Resource Sharing] is a browser security feature, not a bug in my code.&lt;/strong&gt; The scenario it prevents: you're logged into your bank in one tab, you visit a sketchy site in another, and that site's JavaScript quietly fires requests at your bank using your session. To stop that, the browser won't let JS on origin A read a response from origin B &lt;em&gt;unless server B explicitly says it allows A&lt;/em&gt;. Server B says so with an &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; header — which my FastAPI wasn't sending. Getting the error actually meant my request was leaving the browser correctly; the backend just hadn't granted permission yet.&lt;/p&gt;

&lt;p&gt;The fix lives on the &lt;strong&gt;backend&lt;/strong&gt;, via FastAPI's &lt;code&gt;CORSMiddleware&lt;/code&gt; [a &lt;em&gt;middleware&lt;/em&gt; is code that wraps every request/response — perfect for stamping a header onto everything]. Bonus: it ships inside FastAPI, so &lt;strong&gt;no install, no &lt;code&gt;requirements.txt&lt;/code&gt; change&lt;/strong&gt; this time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi.middleware.cors&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;CORSMiddleware&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;CORSMiddleware&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;allow_origins&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:5173&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;  &lt;span class="c1"&gt;# the whitelist — explicit, never "*"
&lt;/span&gt;    &lt;span class="n"&gt;allow_credentials&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;allow_methods&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;allow_headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two decisions worth stating:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never &lt;code&gt;allow_origins=["*"]&lt;/code&gt;.&lt;/strong&gt; It defeats the point, &lt;em&gt;and&lt;/em&gt; the browser flat-out forbids &lt;code&gt;"*"&lt;/code&gt; together with &lt;code&gt;allow_credentials=True&lt;/code&gt;. Always list explicit origins. &lt;code&gt;[Flagged shortcut: I hardcoded the dev URL. Production reads allowed origins from an env var and lists the real Vercel domain — that's my Phase 9 deploy task.]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;allow_headers=["*"]&lt;/code&gt;&lt;/strong&gt; matters because I'm about to start sending an &lt;code&gt;Authorization&lt;/code&gt; header on every protected request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;uvicorn hot-reloaded, I clicked Log in again, and the CORS error was gone — replaced by a clean &lt;strong&gt;401&lt;/strong&gt;, because my dev database was freshly regenerated and had zero users. A 401 round-trip was a &lt;em&gt;success&lt;/em&gt;: the browser-to-backend pipe now worked end to end. I registered a test user through &lt;code&gt;/docs&lt;/code&gt;, typed the same credentials into my form, and got &lt;strong&gt;200&lt;/strong&gt; with a glorious &lt;code&gt;{ access_token: "eyJ...", token_type: "bearer" }&lt;/code&gt;. My React app had fetched its own JWT.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Store the token [the one real security decision this phase]
&lt;/h2&gt;

&lt;p&gt;The token vanished the instant &lt;code&gt;handleSubmit&lt;/code&gt; finished. I needed to &lt;em&gt;keep&lt;/em&gt; it — every future request has to attach it, and a refresh shouldn't log me out. Enter &lt;strong&gt;&lt;code&gt;localStorage&lt;/code&gt;&lt;/strong&gt;: a tiny per-origin key-value store built into the browser, persists across reloads, dead-simple API [&lt;code&gt;setItem&lt;/code&gt;, &lt;code&gt;getItem&lt;/code&gt;, &lt;code&gt;removeItem&lt;/code&gt;], values are always strings.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;[&lt;code&gt;response.ok&lt;/code&gt; is a boolean that's &lt;code&gt;true&lt;/code&gt; for any 2xx — the clean way to ask "did this succeed?" instead of eyeballing status numbers.]&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Here's where I put the security hat back on, because it genuinely matters:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;localStorage&lt;/code&gt; is readable by &lt;strong&gt;any JavaScript running on my page.&lt;/strong&gt; So if an attacker ever injects a script into my app — that's an &lt;strong&gt;XSS&lt;/strong&gt; [cross-site scripting] attack — they can read the token straight out of storage and impersonate the user. That's the real risk, stated plainly.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What I did [flagged learning shortcut]:&lt;/strong&gt; store the JWT in &lt;code&gt;localStorage&lt;/code&gt;. Simple, easy to inspect while learning, sidesteps cookie/CORS-credential complexity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What production does:&lt;/strong&gt; store it in an &lt;strong&gt;&lt;code&gt;httpOnly&lt;/code&gt;, &lt;code&gt;Secure&lt;/code&gt;, &lt;code&gt;SameSite&lt;/code&gt; cookie.&lt;/strong&gt; &lt;code&gt;httpOnly&lt;/code&gt; means JavaScript literally &lt;em&gt;cannot&lt;/em&gt; read it, so even a successful XSS can't steal it, and the browser attaches it automatically. &lt;code&gt;[Know this exists. Not building it now.]&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;React's own defense is worth knowing too: it auto-escapes any text you render, which neutralizes most XSS by default. The danger zone is a prop literally named &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt; — the scary name is the warning. Don't use it and you dodge the common trap.&lt;/p&gt;

&lt;p&gt;I logged in, opened DevTools → Application → Local Storage, saw my &lt;code&gt;token&lt;/code&gt; key sitting there, refreshed the page, and it survived. That persistence is the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: The expense list [useEffect, and the infinite-loop trap]
&lt;/h2&gt;

&lt;p&gt;Reading data needs &lt;strong&gt;&lt;code&gt;useEffect&lt;/code&gt;&lt;/strong&gt;, and I'm glad I learned the trap &lt;em&gt;before&lt;/em&gt; hitting it. A component function re-runs on &lt;em&gt;every&lt;/em&gt; render. So you can't just drop a &lt;code&gt;fetch&lt;/code&gt; in the component body — it'd fire on every render, hammer the API, and [if it sets state] spin into an infinite re-render loop.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;useEffect&lt;/code&gt; lets you say "run this as a &lt;em&gt;side effect&lt;/em&gt;, but only at specific times." The &lt;strong&gt;dependency array&lt;/strong&gt; [second argument] controls when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;[]&lt;/code&gt; empty → run &lt;strong&gt;once&lt;/strong&gt;, right after the component first mounts. Exactly what "fetch when the page loads" wants.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;[x]&lt;/code&gt; → also re-run when &lt;code&gt;x&lt;/code&gt; changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;omitted entirely&lt;/strong&gt; → run after every render. The infinite-loop trap. Don't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One quirk: the effect function itself &lt;strong&gt;can't be &lt;code&gt;async&lt;/code&gt;&lt;/strong&gt;, so the pattern is to define an async function inside it and call it immediately.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ExpenseList&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;expenses&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setExpenses&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;([])&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchExpenses&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:8000/expenses&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="nf"&gt;setExpenses&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;fetchExpenses&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;`Bearer ${token}`&lt;/code&gt; is a &lt;strong&gt;template literal&lt;/strong&gt; — backticks let you embed a variable with &lt;code&gt;${...}&lt;/code&gt;. It produces the &lt;em&gt;exact&lt;/em&gt; string my &lt;code&gt;/docs&lt;/code&gt; "Authorize" button sends, which is how the app proves who it is on every protected request. The fetch came back with a clean empty array &lt;code&gt;[]&lt;/code&gt; — correct, since my test user had no expenses yet. Authentication working from the client side, first time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: The three gaps nobody builds [loading, empty, error]
&lt;/h2&gt;

&lt;p&gt;This is the heart of the phase. And it starts with a gotcha that burns everyone:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #4 — &lt;code&gt;fetch&lt;/code&gt; does NOT throw on a 404 or 500.&lt;/strong&gt; It only rejects when the &lt;em&gt;network itself&lt;/em&gt; fails [server down, CORS block]. A &lt;code&gt;401&lt;/code&gt; or &lt;code&gt;500&lt;/code&gt; is, to &lt;code&gt;fetch&lt;/code&gt;, a perfectly "successful" round-trip that happens to carry a bad status. So you have to check &lt;code&gt;response.ok&lt;/code&gt; &lt;strong&gt;yourself&lt;/strong&gt; and deliberately throw on a bad status. Coming from Python's &lt;code&gt;requests&lt;/code&gt;, this genuinely surprised me.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I handled both failure kinds with &lt;strong&gt;&lt;code&gt;try&lt;/code&gt; / &lt;code&gt;catch&lt;/code&gt; / &lt;code&gt;finally&lt;/code&gt;&lt;/strong&gt; [same shape as Python]. &lt;code&gt;finally&lt;/code&gt; runs no matter what — the perfect home for &lt;code&gt;setLoading(false)&lt;/code&gt;, so a loading spinner can never get stuck on screen. And I rendered the states with &lt;strong&gt;early returns&lt;/strong&gt; in strict priority order: &lt;strong&gt;loading → error → empty → list.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;expenses&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setExpenses&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;([])&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setLoading&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchExpenses&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:8000/expenses&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Request failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="nf"&gt;setExpenses&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Could not load expenses. Please try again.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setLoading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;fetchExpenses&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The part tutorials skip — I &lt;em&gt;tested the error gap on purpose&lt;/em&gt;. I hit &lt;code&gt;Ctrl+C&lt;/code&gt; on the backend, refreshed the browser, and instead of a blank frozen page I got my red "Could not load expenses. Please try again." That's the adversarial habit surviving even without the GAN framing: don't just build the happy path, actively try to break it.&lt;/p&gt;

&lt;p&gt;Then rendering the list with &lt;strong&gt;&lt;code&gt;.map()&lt;/code&gt;&lt;/strong&gt; — the array method that turns each expense object into an &lt;code&gt;&amp;lt;li&amp;gt;&lt;/code&gt;. Every mapped element needs a &lt;strong&gt;&lt;code&gt;key&lt;/code&gt;&lt;/strong&gt; — a stable unique id so React can track rows across re-renders. &lt;strong&gt;Use the database &lt;code&gt;id&lt;/code&gt;, never the array index&lt;/strong&gt; [index breaks the moment the list reorders or an item is removed].&lt;/p&gt;

&lt;p&gt;While wiring this, a small honest scope decision surfaced: my mentor's example row showed a &lt;code&gt;category&lt;/code&gt; field. &lt;strong&gt;My &lt;code&gt;Expense&lt;/code&gt; model doesn't have one.&lt;/strong&gt; Adding it isn't a display tweak — it's a new column, a migration, and schema changes. That's literally what my roadmap's Phase 7 [AI categorization] is &lt;em&gt;for&lt;/em&gt;. So I dropped it rather than scope-creep a clean foundation phase. &lt;code&gt;[category → deferred to Phase 7.]&lt;/code&gt; My real &lt;code&gt;ExpenseRead&lt;/code&gt; returns &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;amount&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;, &lt;code&gt;spent_on&lt;/code&gt;, and timestamps — and a nice detail: because &lt;code&gt;amount&lt;/code&gt; is a &lt;code&gt;Decimal&lt;/code&gt;, Pydantic serializes it to a &lt;strong&gt;string&lt;/strong&gt; on purpose, to protect money from float rounding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Full CRUD from the UI [add, delete, edit]
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Add [POST].&lt;/strong&gt; Same JSON pattern I'll use everywhere except login: &lt;code&gt;Content-Type: application/json&lt;/code&gt; and &lt;code&gt;body: JSON.stringify({...})&lt;/code&gt;. On success, I clear the form and &lt;em&gt;re-fetch the list&lt;/em&gt; so the new row appears — letting the server stay the single source of truth. To make that re-fetch reusable, I pulled &lt;code&gt;fetchExpenses&lt;/code&gt; &lt;strong&gt;out&lt;/strong&gt; of &lt;code&gt;useEffect&lt;/code&gt; into the component body, so both the initial load and every post-write refresh could call it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleAdd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:8000/expenses&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;spent_on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;spentOn&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;setAmount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;setSpentOn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;fetchExpenses&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;[A lovely bit of luck: an &lt;code&gt;&amp;lt;input type="date"&amp;gt;&lt;/code&gt; gives a date-picker whose value is already a &lt;code&gt;"YYYY-MM-DD"&lt;/code&gt; string — exactly what my Python &lt;code&gt;date&lt;/code&gt; field wants, zero conversion.]&lt;/p&gt;

&lt;p&gt;Adding the always-visible form forced a rendering upgrade. Early returns worked when the &lt;em&gt;whole&lt;/em&gt; component was one state, but the form has to show even in the empty state. So I switched to &lt;strong&gt;inline conditional rendering&lt;/strong&gt; with &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;loading&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Loading expenses…&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;red&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;loading&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;expenses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;No expenses yet.&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;loading&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;expenses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="cm"&gt;/* the list */&lt;/span&gt; &lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson worth banking: &lt;strong&gt;early-return when the entire component is one state; inline &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; when only part of the UI changes.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #5 — one missing &lt;code&gt;!&lt;/code&gt; showed two contradictory states at once.&lt;/strong&gt; For a while my app displayed "Could not load expenses" &lt;em&gt;and&lt;/em&gt; "No expenses yet" simultaneously — which is impossible; those are supposed to be mutually exclusive. The cause was hilariously small: my empty-state guard was missing the &lt;code&gt;!error&lt;/code&gt; check. One character [&lt;code&gt;!&lt;/code&gt;] fixed it. A whole render bug lived in a single missing symbol.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Delete [DELETE].&lt;/strong&gt; Two things I hadn't met:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #6 — a 204 has no body, so don't parse one.&lt;/strong&gt; My &lt;code&gt;DELETE&lt;/code&gt; returns &lt;code&gt;204 No Content&lt;/code&gt; — by definition, empty. Calling &lt;code&gt;response.json()&lt;/code&gt; on it would throw. Just check &lt;code&gt;response.ok&lt;/code&gt; and move on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha #7 — passing an argument to a handler needs an arrow wrapper.&lt;/strong&gt; To delete a specific row I wrote &lt;code&gt;onClick={() =&amp;gt; handleDelete(expense.id)}&lt;/code&gt;. If I'd written &lt;code&gt;onClick={handleDelete(expense.id)}&lt;/code&gt; [no arrow], React would &lt;strong&gt;call it immediately during render&lt;/strong&gt; — deleting everything the instant the list appeared. The arrow hands React &lt;em&gt;a function to call later&lt;/em&gt;, on click. Rule of thumb I locked in: &lt;strong&gt;no args → pass the reference [&lt;code&gt;onClick={handleLogout}&lt;/code&gt;]; need args → wrap in an arrow.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Edit [PATCH].&lt;/strong&gt; The biggest UI concept: per-row edit mode. I track &lt;em&gt;which&lt;/em&gt; row is editing with a single &lt;code&gt;editingId&lt;/code&gt; [&lt;code&gt;null&lt;/code&gt; = nobody], and render with a &lt;strong&gt;ternary&lt;/strong&gt; — "if this row is being edited, show inputs; otherwise show text + buttons." Each branch has to return one element, so I wrapped the siblings in a &lt;strong&gt;Fragment&lt;/strong&gt; [&lt;code&gt;&amp;lt;&amp;gt;...&amp;lt;/&amp;gt;&lt;/code&gt;] — an invisible grouping tag that adds no extra &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; to the page.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PATCH&lt;/code&gt; is a &lt;em&gt;partial&lt;/em&gt; update: my backend's &lt;code&gt;ExpenseUpdate&lt;/code&gt; has all-optional fields, and it saves with &lt;code&gt;payload.model_dump(exclude_unset=True)&lt;/code&gt; → &lt;code&gt;setattr&lt;/code&gt; → &lt;code&gt;db.commit()&lt;/code&gt; → &lt;code&gt;db.refresh()&lt;/code&gt;. On success I close edit mode and re-fetch, same source-of-truth habit.&lt;/p&gt;

&lt;p&gt;With edit working, I'd hit the phase's finish line: &lt;strong&gt;log in through the UI and do full create/read/update/delete without ever opening &lt;code&gt;/docs&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8: The bug that made login "do nothing until I refreshed"
&lt;/h2&gt;

&lt;p&gt;This one deserves its own section because it taught me the deepest React lesson of the phase. My symptom: I'd log in, and &lt;em&gt;nothing happened&lt;/em&gt; — until I refreshed the page, at which point it worked perfectly.&lt;/p&gt;

&lt;p&gt;The cause, once I understood it, is fundamental:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #8 — React does not watch &lt;code&gt;localStorage&lt;/code&gt;.&lt;/strong&gt; My login handler wrote the token to &lt;code&gt;localStorage&lt;/code&gt;, but React only re-renders when a piece of &lt;strong&gt;state&lt;/strong&gt; changes. Writing to storage is completely invisible to it. And my &lt;code&gt;ExpenseList&lt;/code&gt; fetched only &lt;em&gt;once, on mount&lt;/em&gt; — so on a fresh load it fired with &lt;code&gt;Bearer null&lt;/code&gt;, failed, and never ran again. Logging in stashed a token, but nothing told React anything had changed. A refresh "fixed" it only because it re-mounted the whole app &lt;em&gt;after&lt;/em&gt; the token was already in storage.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The fix was my first taste of &lt;strong&gt;lifting state up&lt;/strong&gt;: the token belongs in &lt;code&gt;App&lt;/code&gt; [the parent], because &lt;code&gt;App&lt;/code&gt; is what decides between "logged out" and "logged in." I put the token in React state, seeded from storage, and set it on login:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setToken&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;// survives refresh&lt;/span&gt;

&lt;span class="c1"&gt;// in the login success branch:&lt;/span&gt;
&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;setToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// &amp;lt;-- THE missing trigger. State change → re-render → instant switch.&lt;/span&gt;

&lt;span class="c1"&gt;// gate the whole UI on it:&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Smart Expense Manager&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleLogout&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Log out&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ExpenseList&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// otherwise fall through to the login form&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Logout is just login run backwards — clear &lt;em&gt;both&lt;/em&gt; copies of the token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleLogout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// so a refresh won't silently log me back in&lt;/span&gt;
  &lt;span class="nf"&gt;setToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                    &lt;span class="c1"&gt;// so the gate falls through to the login form&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two more real bugs fell out of this, and both are honest lessons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The stray-render leak.&lt;/strong&gt; After adding the gated view, logging out still showed the expense list — because an &lt;em&gt;earlier&lt;/em&gt; &lt;code&gt;&amp;lt;ExpenseList /&amp;gt;&lt;/code&gt; was still sitting in the logged-out return from a previous step. I'd added the new one without removing the old. &lt;code&gt;ExpenseList&lt;/code&gt; should appear in &lt;strong&gt;exactly one place&lt;/strong&gt;. A quick file search [it should show up twice: the import + one render] caught it. Lesson: when you restructure, hunt down the leftovers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incognito confused me for a minute.&lt;/strong&gt; Logged in on a normal window, I opened incognito, logged in &lt;em&gt;there&lt;/em&gt;, and the two windows behaved independently. That's &lt;strong&gt;correct&lt;/strong&gt; — &lt;code&gt;localStorage&lt;/code&gt; is isolated per browser profile, so incognito has its own separate store. [Imagine if it didn't; that'd be a leak.] Two &lt;em&gt;normal&lt;/em&gt; tabs share storage but not live React state, so one won't visually update until it refreshes. &lt;code&gt;[Live cross-tab sync via the storage event = know this exists.]&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bonus: so how does clicking "Save" actually change the database?
&lt;/h2&gt;

&lt;p&gt;This confused me, so I'll write down the model that fixed it: &lt;strong&gt;there are three separate programs, and none can touch the others' memory.&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;My React app&lt;/strong&gt; — JavaScript in the browser. It knows &lt;em&gt;nothing&lt;/em&gt; about the database. All it can do is send HTTP requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My FastAPI backend&lt;/strong&gt; — Python in the &lt;code&gt;uvicorn&lt;/code&gt; terminal. The &lt;em&gt;only&lt;/em&gt; program that talks to the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQLite&lt;/strong&gt; — the &lt;code&gt;expenses.db&lt;/code&gt; file. It only ever hears from the backend.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the Save button &lt;strong&gt;does not save to the database.&lt;/strong&gt; It sends a &lt;code&gt;PATCH&lt;/code&gt; note to the backend &lt;em&gt;asking it&lt;/em&gt; to save. The backend checks my token, finds my row [scoped to my user id — my Phase 3 ownership filter riding along], sets the new values, and runs &lt;strong&gt;&lt;code&gt;db.commit()&lt;/code&gt; — that single line is the actual write to disk.&lt;/strong&gt; Then it replies &lt;code&gt;200&lt;/code&gt;, and my browser re-fetches so the screen matches what the database now says. Every operation in the app is that same shape: &lt;strong&gt;Create&lt;/strong&gt; = POST + commit, &lt;strong&gt;Delete&lt;/strong&gt; = DELETE + commit, &lt;strong&gt;Update&lt;/strong&gt; = PATCH + commit, &lt;strong&gt;Read&lt;/strong&gt; = GET [no commit — nothing changes]. Once that one round-trip clicked, the whole app made sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stuff I want to remember [the honest takeaways]
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The frontend has an exact mirror of the backend's tools: &lt;strong&gt;Node = Python, npm = pip, &lt;code&gt;package.json&lt;/code&gt; = requirements.txt&lt;/strong&gt; [but it auto-freezes], &lt;strong&gt;&lt;code&gt;node_modules&lt;/code&gt; = venv&lt;/strong&gt;, &lt;strong&gt;Vite = uvicorn&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;component&lt;/strong&gt; is a Capitalized function returning &lt;strong&gt;JSX&lt;/strong&gt;. JSX rules that bite: one root element, &lt;code&gt;className&lt;/code&gt; not &lt;code&gt;class&lt;/code&gt;, &lt;code&gt;{ }&lt;/code&gt; for JS values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;useState&lt;/code&gt; returns a &lt;code&gt;[value, setter]&lt;/code&gt; pair. Never reassign the value — always call the setter.&lt;/strong&gt; The setter is the only thing that triggers a re-render.&lt;/li&gt;
&lt;li&gt;React &lt;strong&gt;only re-renders on state change.&lt;/strong&gt; It does &lt;em&gt;not&lt;/em&gt; watch &lt;code&gt;localStorage&lt;/code&gt;. This one fact caused my nastiest bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Controlled inputs&lt;/strong&gt; [&lt;code&gt;value&lt;/code&gt; + &lt;code&gt;onChange&lt;/code&gt;] make state the single source of truth for a form.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;event.preventDefault()&lt;/code&gt; stops the browser's default full-page reload on form submit. You will forget this once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CORS is a browser security feature, not a bug in your code.&lt;/strong&gt; Different port = different origin; the fix is &lt;code&gt;CORSMiddleware&lt;/code&gt; on the backend, with an &lt;em&gt;explicit&lt;/em&gt; origin whitelist [never &lt;code&gt;"*"&lt;/code&gt; with credentials].&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The OAuth2 login endpoint is the odd one out — it wants form-encoded data. Everything else speaks JSON.&lt;/strong&gt; Mixing them up is the classic 422.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fetch&lt;/code&gt; does NOT throw on 4xx/5xx.&lt;/strong&gt; Check &lt;code&gt;response.ok&lt;/code&gt; yourself and throw deliberately. Use &lt;code&gt;try/catch/finally&lt;/code&gt;, and put &lt;code&gt;setLoading(false)&lt;/code&gt; in &lt;code&gt;finally&lt;/code&gt; so it can never get stuck.&lt;/li&gt;
&lt;li&gt;Build the happy path, then &lt;strong&gt;close the three gaps: loading, empty, error&lt;/strong&gt; — and &lt;em&gt;actually test the error state&lt;/em&gt; by killing the backend.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.map()&lt;/code&gt; renders lists; every item needs a &lt;strong&gt;stable &lt;code&gt;key&lt;/code&gt; — use the DB id, never the array index.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; for show-or-nothing; ternary &lt;code&gt;? :&lt;/code&gt; for A-or-B; Fragments &lt;code&gt;&amp;lt;&amp;gt;...&amp;lt;/&amp;gt;&lt;/code&gt;&lt;/strong&gt; to group siblings without an extra &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Passing args to a handler needs an &lt;strong&gt;arrow wrapper&lt;/strong&gt; [&lt;code&gt;() =&amp;gt; fn(id)&lt;/code&gt;] or it fires during render. No args → pass the reference.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;204&lt;/code&gt; response has &lt;strong&gt;no body&lt;/strong&gt; — don't call &lt;code&gt;.json()&lt;/code&gt; on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JWT in &lt;code&gt;localStorage&lt;/code&gt; is a flagged shortcut&lt;/strong&gt; [readable by any JS → XSS risk]. Production uses an &lt;strong&gt;&lt;code&gt;httpOnly Secure SameSite&lt;/code&gt; cookie&lt;/strong&gt;. React auto-escapes text; avoid &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The Save button doesn't save — it &lt;em&gt;asks the backend to&lt;/em&gt;. &lt;strong&gt;&lt;code&gt;db.commit()&lt;/code&gt; is the real write.&lt;/strong&gt; The browser, the backend, and the database are three separate programs passing notes.&lt;/li&gt;
&lt;li&gt;When you restructure, &lt;strong&gt;hunt the leftovers&lt;/strong&gt; — a stray render leaked my list into the logged-out view.&lt;/li&gt;
&lt;li&gt;Commit at every green checkpoint, with a subject + a "why." Small and often beats one heroic commit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next up: Phase 5. The app now has a memory, a mouth, a lock, and — finally — a &lt;strong&gt;face&lt;/strong&gt; a human can log into and use. But that face is honest to a fault: it's a raw list with no styling, no summaries, and a login that just... stops working after 30 minutes with a confusing error [my JWTs expire, and I don't handle it yet]. Phase 5 is where the face gets some &lt;em&gt;expression&lt;/em&gt; — a dashboard, spending insights, and the polish that turns "it works" into "I'd actually use this." That, and I owe this app an "expired-token → send me back to login" fix I flagged and walked away from. See you there.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>react</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Teaching My Backend to Lock the Door — FastAPI Auth, Phase 3</title>
      <dc:creator>Azhar Alvi </dc:creator>
      <pubDate>Sat, 25 Jul 2026 09:06:27 +0000</pubDate>
      <link>https://dev.to/silentcarry/teaching-my-backend-to-lock-the-door-fastapi-auth-phase-3-5b9o</link>
      <guid>https://dev.to/silentcarry/teaching-my-backend-to-lock-the-door-fastapi-auth-phase-3-5b9o</guid>
      <description>&lt;p&gt;&lt;strong&gt;Hash the password, hand out a token, and make absolutely sure no one can read someone else's expenses.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So Phase 2 gave my app a mouth. It could finally &lt;em&gt;talk&lt;/em&gt; — create, read, update, and delete expenses over real HTTP endpoints, all clicking together through &lt;code&gt;/docs&lt;/code&gt;. But there was a giant, deliberately-ignored problem sitting in the middle of it: the door had no lock. Anyone who could reach the server could read, edit, or delete anything. And every expense I created was quietly stamped with the same hardcoded owner — a "dev user" whose id I'd nailed into the code with a &lt;code&gt;# TEMP&lt;/code&gt; note and a promise to fix it "in Phase 3."&lt;/p&gt;

&lt;p&gt;Well. It's Phase 3. Time to pay that debt.&lt;/p&gt;

&lt;p&gt;This is where the app grows a bouncer. The buzzword is &lt;strong&gt;auth&lt;/strong&gt;, which actually hides &lt;em&gt;two&lt;/em&gt; jobs that sound the same and aren't: &lt;strong&gt;authentication&lt;/strong&gt; ["who are you?"] and &lt;strong&gt;authorization&lt;/strong&gt; ["okay, but are you allowed to touch &lt;em&gt;this&lt;/em&gt;?"]. I went in thinking auth was "add a login form" and came out having learned about one-way hashing, signed tokens, a security bug with the excellent name &lt;em&gt;IDOR&lt;/em&gt;, and why the same password can produce two different hashes. Let me dump what I learned [and the parts that tripped me up, because — as usual — there were several].&lt;/p&gt;

&lt;p&gt;Auth is the one phase where you have to stop thinking like a builder and start thinking like the person trying to rob you. So every step below is really "here's a way an attacker wins, and here's the gap I closed to stop them."&lt;/p&gt;

&lt;p&gt;The structure. Let's call it PHASE 3 — The Lock:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Give the &lt;code&gt;User&lt;/code&gt; table somewhere to store a password [a &lt;em&gt;hashed&lt;/em&gt; one, never the real thing]&lt;/li&gt;
&lt;li&gt;Password hashing helpers — turn a password into something safe to store&lt;/li&gt;
&lt;li&gt;POST /auth/register — sign up with a hashed password&lt;/li&gt;
&lt;li&gt;Understand what a JWT actually is [it's just a signed string, and it's readable]&lt;/li&gt;
&lt;li&gt;POST /auth/login — check the password, hand back a token&lt;/li&gt;
&lt;li&gt;get_current_user — the gatekeeper that turns a token back into a user&lt;/li&gt;
&lt;li&gt;Lock every expense endpoint and scope it to the logged-in owner&lt;/li&gt;
&lt;li&gt;Retire the hardcoded &lt;code&gt;DEV_USER_ID&lt;/code&gt; for good&lt;/li&gt;
&lt;li&gt;Prove two users can't see each other's data&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  First, the new ideas [and yes, this time there IS stuff to install]
&lt;/h2&gt;

&lt;p&gt;Unlike Phase 2, this phase actually adds libraries — so the &lt;code&gt;pip freeze&lt;/code&gt; habit is back on. Three new tools, in plain language:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;passlib [with bcrypt]&lt;/strong&gt; — the password shredder. &lt;strong&gt;bcrypt&lt;/strong&gt; is the actual algorithm; passlib is the friendly wrapper around it. Its whole job is to turn a password into a &lt;strong&gt;hash&lt;/strong&gt; — a scrambled string you can store safely — using a method that's &lt;em&gt;deliberately slow&lt;/em&gt; and &lt;em&gt;salted&lt;/em&gt; [more on both of those below].&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PyJWT&lt;/strong&gt; — the token machine. It creates and verifies &lt;strong&gt;JWTs&lt;/strong&gt;, the signed strings that let a user prove "I'm logged in" on every request without re-sending their password. [Gotcha you'll hit: you &lt;code&gt;pip install pyjwt&lt;/code&gt; but you &lt;code&gt;import jwt&lt;/code&gt;. The names don't match. File that away now.]&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;python-multipart&lt;/strong&gt; — an unglamorous helper that lets FastAPI read &lt;em&gt;form&lt;/em&gt; data, which the standard login flow uses. You don't call it directly; FastAPI just needs it present.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"passlib[bcrypt]"&lt;/span&gt; pyjwt python-multipart
pip freeze &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The quotes around &lt;code&gt;"passlib[bcrypt]"&lt;/code&gt; matter — the brackets tell pip to pull bcrypt in as an extra. And the moment you install something, &lt;strong&gt;freeze it&lt;/strong&gt;, so the next machine [including future-you] rebuilds the identical environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Give users a place to store a password [a hashed one]
&lt;/h2&gt;

&lt;p&gt;Here's the rule that governs this entire phase, and it's worth tattooing somewhere: &lt;strong&gt;you never store the password. You store a one-way hash of it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My &lt;code&gt;User&lt;/code&gt; model from Phase 1 had &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, and timestamps — but no password field at all, because I built it before auth existed. So it needs one new column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;hashed_password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two deliberate choices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's called &lt;code&gt;hashed_password&lt;/code&gt;, not &lt;code&gt;password&lt;/code&gt;.&lt;/strong&gt; The name is a permanent reminder to everyone [me, in three weeks] that this field holds a hash, &lt;em&gt;never&lt;/em&gt; plaintext. If you ever spot raw text in there, something is badly broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's not unique and not indexed&lt;/strong&gt; [unlike &lt;code&gt;email&lt;/code&gt;]. You never look a user up &lt;em&gt;by&lt;/em&gt; their hash — you find them by email, then verify the hash in code. Indexing it would be wasted effort.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A new column means a new migration. Same autogenerate → review → apply rhythm from Phase 1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;alembic revision &lt;span class="nt"&gt;--autogenerate&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"add hashed_password to users"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I reviewed the generated file [&lt;code&gt;op.add_column(... nullable=False)&lt;/code&gt;] and then hit the wrinkle that became my first real lesson this phase.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #1 — the NOT NULL wall.&lt;/strong&gt; SQLite refuses to add a &lt;code&gt;NOT NULL&lt;/code&gt; column to a table that already has rows, because those existing rows have no value for it. My table had that old seeded dev user in it. Two honest paths: the &lt;em&gt;production&lt;/em&gt; way [add the column nullable, backfill every row, then a second migration to flip it to &lt;code&gt;NOT NULL&lt;/code&gt; — zero data loss], or the &lt;em&gt;learning&lt;/em&gt; way [my only data was throwaway, so wipe it and rebuild]. My mentor made me say out loud that we were taking a shortcut. I took it — but now I know the real move for when the data actually matters.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;⚠️ &lt;strong&gt;Destructive-command warning&lt;/strong&gt; [the first of this phase]: rebuilding from scratch &lt;em&gt;deletes every local row&lt;/em&gt;. Safe here only because it's junk dev data. Rather than delete the &lt;code&gt;.db&lt;/code&gt; file by hand, I stayed inside Alembic — which also proves my whole migration chain works end to end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;alembic downgrade base   &lt;span class="c"&gt;# drops everything back to empty [the destructive line]&lt;/span&gt;
alembic upgrade &lt;span class="nb"&gt;head&lt;/span&gt;     &lt;span class="c"&gt;# replays all migrations onto empty tables&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: The hashing helpers [hashing is NOT encryption]
&lt;/h2&gt;

&lt;p&gt;Before I could register anyone, I needed two tiny functions: one to hash a password on the way in, one to verify a login attempt later. Best practice says security code lives in its own file, so this is a new &lt;code&gt;security.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;passlib.context&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;CryptContext&lt;/span&gt;

&lt;span class="n"&gt;pwd_context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CryptContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schemes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bcrypt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;deprecated&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;hash_password&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pwd_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_password&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plain_password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashed_password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pwd_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plain_password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashed_password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The single most important concept here — and it took a beat to click:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hashing is one-way. Encryption is two-way.&lt;/strong&gt; You can &lt;em&gt;encrypt&lt;/em&gt; something and later decrypt it back. You can &lt;em&gt;hash&lt;/em&gt; a password, but you can &lt;strong&gt;never&lt;/strong&gt; turn the hash back into the password. That's the entire point: even &lt;em&gt;I&lt;/em&gt;, running the server, can't see anyone's password. If my whole database gets stolen, the attacker gets a pile of useless &lt;code&gt;$2b$...&lt;/code&gt; strings, not passwords. &lt;strong&gt;That's the first gap closed.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;bcrypt is deliberately slow.&lt;/strong&gt; Sounds like a bug; it's a feature. Slowness makes brute-forcing millions of guesses painfully expensive for an attacker while costing a real login a few harmless milliseconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;bcrypt auto-salts.&lt;/strong&gt; It mixes a random value into every hash, so two people with the same password get &lt;em&gt;different&lt;/em&gt; hashes. That kills "rainbow table" attacks [precomputed hash lookups]. I proved this to myself: hashing &lt;code&gt;"supersecret123"&lt;/code&gt; twice gave two totally different strings. Same input, different output — the salt, visibly doing its job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;deprecated="auto"&lt;/code&gt;&lt;/strong&gt; is the sneaky-clever bit. Because every hash records &lt;em&gt;which&lt;/em&gt; algorithm made it [the &lt;code&gt;$2b$&lt;/code&gt; prefix is bcrypt's ID card], I can add a newer algorithm years from now and passlib will keep verifying old hashes &lt;em&gt;and&lt;/em&gt; silently upgrade them the next time each user logs in. Nobody gets locked out, nobody resets their password. Algorithm agility, for one word of config.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I tested it in a plain &lt;code&gt;python&lt;/code&gt; shell: &lt;code&gt;hash_password("supersecret123")&lt;/code&gt; gave a long &lt;code&gt;$2b$...&lt;/code&gt; string, &lt;code&gt;verify_password("supersecret123", h)&lt;/code&gt; returned &lt;code&gt;True&lt;/code&gt;, and &lt;code&gt;verify_password("wrong", h)&lt;/code&gt; returned &lt;code&gt;False&lt;/code&gt;. Then the version gods showed up.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #2 — the 72-byte error on a 14-character password.&lt;/strong&gt; My first real hash blew up with &lt;code&gt;ValueError: password cannot be longer than 72 bytes&lt;/code&gt;. My password was 14 characters. What?! The culprit: passlib 1.7.4 is old and unmaintained, and modern bcrypt [4.1+] changed an interface it relies on. On its first run, passlib runs an internal self-test that trips the new bcrypt's 72-byte guard — &lt;em&gt;before my password is ever touched&lt;/em&gt;. The fix was to pin bcrypt to the last version passlib understands:&lt;/p&gt;


&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"bcrypt==4.0.1"&lt;/span&gt;
pip freeze &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;This was also the moment "why do we freeze?" stopped being abstract — without the pin, a fresh install would grab the broken-for-me newest bcrypt all over again. [The modern alternative is a library called &lt;code&gt;pwdlib&lt;/code&gt;; passlib is on its way out. Noted for a future refactor.]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 3: POST /auth/register [where the hash finally gets used]
&lt;/h2&gt;

&lt;p&gt;Two schemas, exactly like Phase 2's input/output split — but with one security rule bolted on. In &lt;code&gt;schemas.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;What a client sends to register.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserRead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;What the server sends back — no password, no hash, EVER.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;

    &lt;span class="n"&gt;model_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ConfigDict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from_attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at what &lt;code&gt;UserRead&lt;/code&gt; is missing: the password &lt;em&gt;and&lt;/em&gt; the hash. This is a real gap — if the stored hash ever showed up in an API response, an attacker who can read responses gets a head start on cracking it. Leaving it out of the output schema makes that leak &lt;strong&gt;structurally impossible&lt;/strong&gt;. Same trick as &lt;code&gt;response_model=ExpenseRead&lt;/code&gt; in Phase 2, now doing security work.&lt;/p&gt;

&lt;p&gt;The endpoint, in &lt;code&gt;main.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/auth/register&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;UserRead&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTP_201_CREATED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;UserCreate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Email already registered&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;hashed_password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;hash_password&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;hashed_password=hash_password(payload.password)&lt;/code&gt;&lt;/strong&gt; is the one line that makes this safe. The plaintext exists only long enough to be hashed, right here, and is never stored. What lands in the database is the &lt;code&gt;$2b$...&lt;/code&gt; string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;/auth/&lt;/code&gt; prefix&lt;/strong&gt; namespaces auth routes together — tidy as the API grows. [The neater-still version is a separate &lt;code&gt;APIRouter&lt;/code&gt; file; leaving that for later so I'm not juggling files mid-concept.]&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;409 Conflict&lt;/strong&gt; is the precise code for "your request is fine, but it clashes with something that already exists" — here, a taken email. More specific than a generic 400. [The fully robust version wraps the insert in a &lt;code&gt;try/except IntegrityError&lt;/code&gt; to close a tiny race where two people register the same email at the same instant; the DB's unique constraint still protects integrity either way. Noted, not built.]&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tested through &lt;code&gt;/docs&lt;/code&gt;: register → &lt;strong&gt;201&lt;/strong&gt;, and the response showed &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt; and — crucially — &lt;strong&gt;no&lt;/strong&gt; &lt;code&gt;hashed_password&lt;/code&gt;. Registering the same email again → &lt;strong&gt;409&lt;/strong&gt;. &lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: What a JWT actually is [read this before you trust one]
&lt;/h2&gt;

&lt;p&gt;This was the concept I most needed to &lt;em&gt;get&lt;/em&gt; before writing more code. A &lt;strong&gt;JWT&lt;/strong&gt; [JSON Web Token, said "jot"] is just a &lt;strong&gt;signed string&lt;/strong&gt; the server hands you at login. You send it back on every future request, and it proves "I'm logged in" — &lt;em&gt;without&lt;/em&gt; the server having to remember sessions. The token itself carries the proof.&lt;/p&gt;

&lt;p&gt;It has &lt;strong&gt;three parts separated by dots&lt;/strong&gt;: &lt;code&gt;header.payload.signature&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Header&lt;/strong&gt; — which signing algorithm was used [I used &lt;code&gt;HS256&lt;/code&gt;].&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payload&lt;/strong&gt; — the "claims," i.e. the data. I put in &lt;code&gt;sub&lt;/code&gt; [subject — who the token is about; I store the user's id] and &lt;code&gt;exp&lt;/code&gt; [expiry].&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signature&lt;/strong&gt; — the header and payload, signed with my secret key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the thing that reframed everything for me: &lt;strong&gt;the payload is only base64-encoded, not encrypted.&lt;/strong&gt; Anyone who grabs the token can &lt;em&gt;read&lt;/em&gt; it — paste one into jwt.io and you'll see the user id in plain text. So the rule is: &lt;strong&gt;never put anything secret in a JWT.&lt;/strong&gt; What a token buys you isn't secrecy — it's &lt;strong&gt;integrity&lt;/strong&gt;. Change one character of the payload [say, to a different user's id] and the signature no longer matches, so the server rejects it. An attacker can't forge a valid signature because &lt;strong&gt;only my server knows the secret key.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Which is exactly why that key can never leak. If someone learns my &lt;code&gt;SECRET_KEY&lt;/code&gt;, they can mint valid tokens for &lt;em&gt;any&lt;/em&gt; user — a total bypass. So it lives in &lt;code&gt;.env&lt;/code&gt; [already gitignored since Phase 0], generated with real randomness:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import secrets; print(secrets.token_hex(32))"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;that_64_char_hex_value&lt;/span&gt;
&lt;span class="py"&gt;ACCESS_TOKEN_EXPIRE_MINUTES&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That 30-minute expiry closes another gap: a &lt;em&gt;stolen&lt;/em&gt; token stops working within half an hour instead of forever. [Real apps pair a short access token with a longer "refresh token" so users aren't logged out constantly. Not building refresh tokens this phase — that'd be over-engineering right now.]&lt;/p&gt;

&lt;p&gt;Then I wrote the token minter in &lt;code&gt;security.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_access_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;expire&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ACCESS_TOKEN_EXPIRE_MINUTES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;expire&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ALGORITHM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;strong&gt;timezone-aware UTC&lt;/strong&gt; expiry — same discipline as Phase 1's &lt;code&gt;timezone=True&lt;/code&gt; columns. Auth timestamps must be unambiguous across servers.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #3 — the misspelled keyword that wasn't a version bug.&lt;/strong&gt; Setting up &lt;code&gt;CryptContext&lt;/code&gt; earlier, I hit &lt;code&gt;KeyError: unknown CryptContext keyword&lt;/code&gt;. I assumed another bcrypt version mess. But my mentor made me &lt;em&gt;read the traceback bottom-up&lt;/em&gt; first — and the last line said it plainly: an argument I passed doesn't exist. I'd typed &lt;code&gt;depreciated&lt;/code&gt; [the accounting word, with an &lt;code&gt;i&lt;/code&gt;] instead of &lt;code&gt;deprecated&lt;/code&gt;. Case-and-spelling-sensitive libraries do not forgive. Reading the traceback saved me from "fixing" something that was never broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha #4 — a &lt;code&gt;.env&lt;/code&gt; file does nothing on its own.&lt;/strong&gt; My &lt;code&gt;SECRET_KEY = os.environ["SECRET_KEY"]&lt;/code&gt; crashed with a &lt;code&gt;KeyError&lt;/code&gt;. Turns out I'd been &lt;em&gt;reading&lt;/em&gt; env vars with &lt;code&gt;os.getenv&lt;/code&gt; all along but never actually &lt;em&gt;loading&lt;/em&gt; the &lt;code&gt;.env&lt;/code&gt; file into the environment — my &lt;code&gt;DATABASE_URL&lt;/code&gt; only "worked" because it had a default fallback that quietly kicked in. A &lt;code&gt;.env&lt;/code&gt; file is just text; something has to copy it into the environment first. That something is &lt;code&gt;load_dotenv()&lt;/code&gt;:&lt;/p&gt;


&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dotenv&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;load_dotenv&lt;/span&gt;
&lt;span class="nf"&gt;load_dotenv&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# must run BEFORE anything reads a variable
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;Lesson that stuck: &lt;strong&gt;loading and reading are two different jobs.&lt;/strong&gt; &lt;code&gt;load_dotenv()&lt;/code&gt; loads; &lt;code&gt;os.getenv()&lt;/code&gt; reads. And my strict, no-fallback &lt;code&gt;SECRET_KEY&lt;/code&gt; line is what exposed the gap the fallback had been hiding — fail-fast earning its keep.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 5: POST /auth/login [check the password, hand back a token]
&lt;/h2&gt;

&lt;p&gt;This is where hashing and JWTs meet. I built login the standard FastAPI way, with &lt;code&gt;OAuth2PasswordRequestForm&lt;/code&gt; — which, bonus, lights up the green &lt;strong&gt;Authorize&lt;/strong&gt; button in &lt;code&gt;/docs&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/auth/login&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;form_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OAuth2PasswordRequestForm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;form_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;verify_password&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;form_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hashed_password&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Incorrect email or password&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WWW-Authenticate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;access_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_access_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bearer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The quirk everyone hits:&lt;/strong&gt; the OAuth2 spec calls the field &lt;code&gt;username&lt;/code&gt;, but I put the &lt;em&gt;email&lt;/em&gt; there. So at login you type your email into the box labeled "username." Normal in FastAPI land.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The vague error is on purpose.&lt;/strong&gt; Whether the email doesn't exist &lt;em&gt;or&lt;/em&gt; the password is wrong, I return the exact same &lt;code&gt;401&lt;/code&gt; with the same message. If I distinguished them, an attacker could probe which emails are registered — a leak called &lt;em&gt;user enumeration&lt;/em&gt;. One generic message, closed. &lt;strong&gt;Do this now, it's free.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;subject=str(user.id)&lt;/code&gt;&lt;/strong&gt; stamps the user's id into the token's &lt;code&gt;sub&lt;/code&gt; claim. That breadcrumb is the whole point — it's how the next step figures out &lt;em&gt;who&lt;/em&gt; a request is from, with no password involved.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The docs also show &lt;code&gt;grant_type&lt;/code&gt;, &lt;code&gt;scope&lt;/code&gt;, &lt;code&gt;client_id&lt;/code&gt;, &lt;code&gt;client_secret&lt;/code&gt; fields — all part of the full OAuth2 spec, all safely ignorable for my app. Only &lt;code&gt;username&lt;/code&gt; and &lt;code&gt;password&lt;/code&gt; matter here.&lt;/p&gt;

&lt;p&gt;Tested: right email + password → &lt;strong&gt;200&lt;/strong&gt; with a token; wrong password → &lt;strong&gt;401&lt;/strong&gt;. And it taught me a bit of Python grammar I'd been fuzzy on — &lt;code&gt;:&lt;/code&gt; &lt;em&gt;describes&lt;/em&gt; [&lt;code&gt;amount: Decimal&lt;/code&gt;, a dict pair &lt;code&gt;{"key": "value"}&lt;/code&gt;, the &lt;code&gt;:&lt;/code&gt; that opens a block] while &lt;code&gt;=&lt;/code&gt; &lt;em&gt;assigns&lt;/em&gt; [&lt;code&gt;x = 5&lt;/code&gt;, or a keyword arg &lt;code&gt;status_code=401&lt;/code&gt;]. Same word can flip: &lt;code&gt;subject: str&lt;/code&gt; in a definition vs &lt;code&gt;subject=...&lt;/code&gt; in a call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: get_current_user [the gatekeeper]
&lt;/h2&gt;

&lt;p&gt;This is the piece every locked endpoint leans on. It turns a token back into a real, live user — the concrete form of &lt;em&gt;authentication&lt;/em&gt;. First a decode helper in &lt;code&gt;security.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decode_access_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ALGORITHM&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;jwt.decode&lt;/code&gt; does two jobs at once: verifies the signature [proving I minted it, untampered] &lt;em&gt;and&lt;/em&gt; checks the &lt;code&gt;exp&lt;/code&gt; [rejecting expired tokens]. Either failure raises. Then the dependency in &lt;code&gt;main.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;oauth2_scheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OAuth2PasswordBearer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokenUrl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auth/login&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oauth2_scheme&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;credentials_exception&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Could not validate credentials&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WWW-Authenticate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decode_access_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;credentials_exception&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InvalidTokenError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;credentials_exception&lt;/span&gt;

    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;credentials_exception&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;OAuth2PasswordBearer&lt;/code&gt;&lt;/strong&gt; automatically pulls the token out of the incoming &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt; header [401 if it's missing] and is what finally enables the Authorize button.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One &lt;code&gt;except jwt.InvalidTokenError&lt;/code&gt;&lt;/strong&gt; covers a bad signature, a malformed token, &lt;em&gt;and&lt;/em&gt; an expired one [expiry raises a subclass], all funnelling to the same generic 401. No-leak discipline again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;db.get(User, ...)&lt;/code&gt;&lt;/strong&gt; fetches the actual user rather than blindly trusting the token — so a deleted user's leftover token stops working. Returning the &lt;code&gt;User&lt;/code&gt; object is the magic: any endpoint that depends on this gets the logged-in user handed straight to it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I proved it with a throwaway &lt;code&gt;GET /auth/me&lt;/code&gt; that just returns &lt;code&gt;current_user&lt;/code&gt;: authorized → &lt;strong&gt;200&lt;/strong&gt; with my user; no token → &lt;strong&gt;401&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #5 — case-sensitivity, twice.&lt;/strong&gt; First &lt;code&gt;Oauth2PasswordBearer is not defined&lt;/code&gt;, then again — because "OAuth" is an acronym and &lt;em&gt;both&lt;/em&gt; the O and the A are uppercase: &lt;code&gt;OAuth2PasswordBearer&lt;/code&gt;. I'd lowercased the A. Python doesn't care that it's "obviously" the same word.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha #6 — order matters for dependencies.&lt;/strong&gt; I also hit &lt;code&gt;get_current_user is not defined&lt;/code&gt; on an endpoint. Not a typo this time — an &lt;em&gt;ordering&lt;/em&gt; problem. Python evaluates &lt;code&gt;Depends(get_current_user)&lt;/code&gt; in the function's default arguments &lt;em&gt;the moment it reads the &lt;code&gt;def&lt;/code&gt; line&lt;/em&gt;, so &lt;code&gt;get_current_user&lt;/code&gt; has to be &lt;strong&gt;defined above&lt;/strong&gt; any endpoint that uses it. Moved the gatekeeper up near the top of &lt;code&gt;main.py&lt;/code&gt;, above the routes. [Same reason &lt;code&gt;get_db&lt;/code&gt; has always worked — defined before its consumers.]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 7: Lock the endpoints and scope them to the owner [the boss fight]
&lt;/h2&gt;

&lt;p&gt;This is what the whole phase was building toward — and it's the real difference between &lt;em&gt;authentication&lt;/em&gt; [we know who you are] and &lt;em&gt;authorization&lt;/em&gt; [you may only touch &lt;em&gt;your own&lt;/em&gt; rows]. Every expense endpoint gets the same two changes: add &lt;code&gt;current_user: User = Depends(get_current_user)&lt;/code&gt;, then use &lt;code&gt;current_user.id&lt;/code&gt; — for writes, stamp it; for reads, &lt;strong&gt;filter by it&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Create finally retires the shortcut:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;expense&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_dump&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# was DEV_USER_ID
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the isolation-critical read — fetch by id &lt;strong&gt;AND&lt;/strong&gt; owner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/expenses/{expense_id}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ExpenseRead&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_one_expense&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;expense_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;expense&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;expense_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;expense&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Expense not found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;expense&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That double &lt;code&gt;.where(...)&lt;/code&gt; closes the scariest gap in the phase — &lt;strong&gt;IDOR&lt;/strong&gt; [Insecure Direct Object Reference]: the bug where a logged-in User A simply &lt;em&gt;asks for&lt;/em&gt; User B's expense by its id. Because the query filters on &lt;code&gt;user_id&lt;/code&gt; too, A's request for B's row returns nothing → a clean &lt;strong&gt;404&lt;/strong&gt;. The ownership check lives &lt;em&gt;in the query itself&lt;/em&gt;, so I literally cannot forget it later. The list, PATCH, and DELETE endpoints all got the same treatment [filter the list by owner; fetch-scoped-by-owner before updating or deleting], so it's impossible to even &lt;em&gt;load&lt;/em&gt; someone else's row to change it.&lt;/p&gt;

&lt;p&gt;Two small but real decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;404, not 403, when the row isn't yours.&lt;/strong&gt; Saying 403 ["that exists, but you can't have it"] would leak that the id exists. 404 reveals nothing. Same no-leak instinct as the login error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;db.scalars(...)&lt;/code&gt; + &lt;code&gt;.first()&lt;/code&gt; / &lt;code&gt;.all()&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;db.scalars()&lt;/code&gt; hands me clean model objects [not row-tuples], &lt;code&gt;.first()&lt;/code&gt; gives one object or &lt;code&gt;None&lt;/code&gt; [get-one], &lt;code&gt;.all()&lt;/code&gt; gives a list [the list endpoint]. Different endings for different needs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then the symbolic finish line — deleting the hardcoded owner. But not before checking it's actually unused:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"DEV_USER_ID"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Habit worth keeping:&lt;/strong&gt; grep-before-delete. It turns "I &lt;em&gt;think&lt;/em&gt; it's unused" into "I've &lt;em&gt;confirmed&lt;/em&gt; it's unused." Only the definition line showed up, so I deleted it. &lt;code&gt;DEV_USER_ID&lt;/code&gt; is officially retired — 3 phases after I promised it would be.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 8: Prove it [the part tutorials skip]
&lt;/h2&gt;

&lt;p&gt;Writing auth code isn't the same as &lt;em&gt;verifying&lt;/em&gt; auth works. So I put on the attacker hat and ran the isolation proof my roadmap demanded, entirely through &lt;code&gt;/docs&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Registered a second user, Bob.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;As User A:&lt;/strong&gt; created an expense [id 1], confirmed &lt;code&gt;GET /expenses&lt;/code&gt; showed only mine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logged out, authorized as Bob&lt;/strong&gt; — the attacker:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GET /expenses&lt;/code&gt; → &lt;strong&gt;empty list &lt;code&gt;[]&lt;/code&gt;.&lt;/strong&gt; Bob has nothing. [If A's expense showed up here, isolation was broken.]&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /expenses/1&lt;/code&gt;, &lt;code&gt;PATCH /expenses/1&lt;/code&gt;, &lt;code&gt;DELETE /expenses/1&lt;/code&gt; → &lt;strong&gt;404, 404, 404.&lt;/strong&gt; Bob can't read, edit, or delete A's row even though he knows its id.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Back as User A:&lt;/strong&gt; &lt;code&gt;GET /expenses/1&lt;/code&gt; → &lt;strong&gt;200&lt;/strong&gt;, untouched. Bob's attacks never landed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Bob is a perfectly valid, authenticated user — and still can't touch A's data. &lt;strong&gt;That's the gap closed, and it's the difference between authentication and authorization made concrete.&lt;/strong&gt; Passing this test is the moment the phase actually ended.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stuff I want to remember [the honest takeaways]
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never store a password. Store a one-way hash.&lt;/strong&gt; Hashing ≠ encryption — there's no "decrypt" button, and that's the point.&lt;/li&gt;
&lt;li&gt;bcrypt is &lt;em&gt;deliberately slow&lt;/em&gt; and &lt;em&gt;auto-salted&lt;/em&gt; — same password, different hash every time.&lt;/li&gt;
&lt;li&gt;A JWT's payload is &lt;strong&gt;readable, not encrypted.&lt;/strong&gt; Never put secrets in it. What it gives you is &lt;em&gt;integrity&lt;/em&gt;, guarded by a signature only your server can produce.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;SECRET_KEY&lt;/code&gt; leaking = total auth bypass. It lives in &lt;code&gt;.env&lt;/code&gt;, never in git, and it's generated with real randomness.&lt;/li&gt;
&lt;li&gt;Short token expiry [&lt;code&gt;exp&lt;/code&gt;] limits the damage of a stolen token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;401 = "you failed to authenticate."&lt;/strong&gt; For "that's not yours," return &lt;strong&gt;404, not 403&lt;/strong&gt; — 403 leaks that the thing exists.&lt;/li&gt;
&lt;li&gt;Keep login and validation errors &lt;strong&gt;vague and identical&lt;/strong&gt; — specific ones leak which emails/rows exist [user enumeration].&lt;/li&gt;
&lt;li&gt;Put the ownership filter &lt;strong&gt;in the query&lt;/strong&gt; [&lt;code&gt;.where(user_id == current_user.id)&lt;/code&gt;], not in an afterthought check you can forget.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;os.getenv&lt;/code&gt; &lt;em&gt;reads&lt;/em&gt; env vars; &lt;code&gt;load_dotenv()&lt;/code&gt; &lt;em&gt;loads&lt;/em&gt; the &lt;code&gt;.env&lt;/code&gt; file. Different jobs — you need both.&lt;/li&gt;
&lt;li&gt;Dependencies must be &lt;strong&gt;defined above&lt;/strong&gt; the endpoints that use them — default args evaluate at &lt;code&gt;def&lt;/code&gt; time.&lt;/li&gt;
&lt;li&gt;Read tracebacks &lt;strong&gt;bottom-up&lt;/strong&gt;; the last line is the real error. It'll save you from fixing the wrong thing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;grep&lt;/code&gt; before you delete. Confirm dead code is actually dead.&lt;/li&gt;
&lt;li&gt;Freeze after every install — and sometimes you have to &lt;em&gt;pin&lt;/em&gt; a version [&lt;code&gt;bcrypt==4.0.1&lt;/code&gt;] to keep an older library happy.&lt;/li&gt;
&lt;li&gt;When you cut a corner, label it — and Phase-N-later, actually go back and pay it off. &lt;code&gt;DEV_USER_ID&lt;/code&gt;, you will not be missed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next up: Phase 4. The app now has a memory, a mouth, and a lock on the door. It knows who you are and only shows you your own stuff. What it &lt;em&gt;doesn't&lt;/em&gt; have yet is a way to make sense of all that data at scale — filtering, sorting, pagination, maybe some summaries. If Phase 3 gave the app a bouncer, Phase 4 teaches it to actually organize the room. See you there.&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>python</category>
      <category>security</category>
    </item>
    <item>
      <title>Teaching My Backend to Listen and Reply — FastAPI CRUD, Phase 2</title>
      <dc:creator>Azhar Alvi </dc:creator>
      <pubDate>Wed, 22 Jul 2026 09:38:53 +0000</pubDate>
      <link>https://dev.to/silentcarry/teaching-my-backend-to-listen-and-reply-fastapi-crud-phase-2-pjh</link>
      <guid>https://dev.to/silentcarry/teaching-my-backend-to-listen-and-reply-fastapi-crud-phase-2-pjh</guid>
      <description>&lt;p&gt;&lt;strong&gt;Validate what comes in, shape what goes out, and give every outcome its proper status code.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;talk&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;Phase 2 fixes that. This is where the app grows a mouth — real HTTP endpoints you hit through the browser. The buzzword is &lt;strong&gt;CRUD&lt;/strong&gt;: 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].&lt;/p&gt;

&lt;p&gt;The Structure is as follows. Lets call it PHASE 2 — The Endpoints:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  First, the two new ideas [and no, nothing to install this time]
&lt;/h2&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pydantic&lt;/strong&gt; — the bouncer at the door. Your SQLAlchemy models describe how data is &lt;em&gt;stored&lt;/em&gt;; Pydantic describes the &lt;em&gt;shape data must have to cross the border of your API&lt;/em&gt;. It checks types, rejects garbage, and turns raw JSON into a clean Python object before it gets anywhere near your database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dependency injection&lt;/strong&gt; — 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" &lt;strong&gt;once&lt;/strong&gt;, and every endpoint just declares "I need one." FastAPI runs it for you and cleans up after. You'll see it as &lt;code&gt;Depends(...)&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Two contracts — one for input, one for output
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ConfigDict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExpenseCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;What the CLIENT is allowed to send.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;spent_on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExpenseRead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;What the SERVER sends back — includes DB-generated fields.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;spent_on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;
    &lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;

    &lt;span class="n"&gt;model_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ConfigDict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from_attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why two, and not one? Because input and output have genuinely different jobs:&lt;/p&gt;

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

&lt;p&gt;And yep, &lt;code&gt;amount&lt;/code&gt; stays &lt;code&gt;Decimal&lt;/code&gt; here too. Same Phase 1 rule: money never touches a float, not even at the API boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick detour: who owns this expense?
&lt;/h2&gt;

&lt;p&gt;Before I could create anything, I hit a wall that's actually a good lesson. My &lt;code&gt;Expense&lt;/code&gt; has a &lt;code&gt;user_id&lt;/code&gt; 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?&lt;/p&gt;

&lt;p&gt;The honest answer: I cheated, on purpose, and wrote down that I cheated.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;DEV_USER_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;  &lt;span class="c1"&gt;# TEMP: Phase 3 replaces this with the authenticated user
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;em&gt;not&lt;/em&gt; tangling auth into CRUD. So I structured it so Phase 3 is a one-line swap: &lt;code&gt;DEV_USER_ID&lt;/code&gt; → &lt;code&gt;current_user.id&lt;/code&gt;. Shortcut taken, shortcut labeled.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #1:&lt;/strong&gt; My first attempt to seed the dev user blew up with &lt;code&gt;NOT NULL constraint failed: users.name&lt;/code&gt;. My &lt;code&gt;User&lt;/code&gt; model requires a &lt;code&gt;name&lt;/code&gt;, 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 &lt;code&gt;ROLLBACK&lt;/code&gt;, so no half-broken row got saved. The transaction safety I read about in Phase 1, actually doing its thing.]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 2: The session dependency [write it once, never leak a connection]
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;database.py&lt;/code&gt; once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections.abc&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Generator&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy.orm&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Generator&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SessionLocal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The weird-looking part is the &lt;strong&gt;&lt;code&gt;yield&lt;/code&gt; inside a &lt;code&gt;try/finally&lt;/code&gt;&lt;/strong&gt;. A normal function &lt;code&gt;return&lt;/code&gt;s and it's done. This one &lt;code&gt;yield&lt;/code&gt;s the session out to my endpoint, &lt;em&gt;pauses&lt;/em&gt; while the endpoint runs, then comes back and runs the &lt;code&gt;finally&lt;/code&gt; block no matter what. That &lt;code&gt;finally&lt;/code&gt; is the whole point: &lt;code&gt;db.close()&lt;/code&gt; is guaranteed to run on success &lt;em&gt;and&lt;/em&gt; 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: &lt;code&gt;yield&lt;/code&gt; = "hand it out, then come back and clean up."]&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: POST — create an expense [it all comes together]
&lt;/h2&gt;

&lt;p&gt;This is where Pydantic, the session dependency, and my model finally click into one endpoint. It goes in &lt;code&gt;main.py&lt;/code&gt;, where &lt;code&gt;app&lt;/code&gt; lives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy.orm&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;database&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;get_db&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Expense&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;schemas&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ExpenseCreate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ExpenseRead&lt;/span&gt;


&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/expenses&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ExpenseRead&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTP_201_CREATED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_expense&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ExpenseCreate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;expense&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_dump&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;DEV_USER_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expense&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expense&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;expense&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every line is pulling weight, and it took me a minute to appreciate that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;@app.post(...)&lt;/code&gt; decorator&lt;/strong&gt; is the wiring. &lt;code&gt;@&lt;/code&gt; means "attach behavior to this function" — here, "when a POST hits &lt;code&gt;/expenses&lt;/code&gt;, run me." I write the logic; the decorator handles reading the request and sending the response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;response_model=ExpenseRead&lt;/code&gt;&lt;/strong&gt; runs whatever I return &lt;em&gt;through&lt;/em&gt; 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 &lt;code&gt;ExpenseRead&lt;/code&gt;'s fields go out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;status_code=201&lt;/code&gt;&lt;/strong&gt; because REST says "created" is a 201, not a plain 200. Small thing, correct thing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;payload: ExpenseCreate&lt;/code&gt;&lt;/strong&gt; — 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Depends(get_db)&lt;/code&gt;&lt;/strong&gt; — the Step 2 dependency in action. I just get a ready-to-use &lt;code&gt;db&lt;/code&gt; that closes itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;`Expense(&lt;/strong&gt;payload.model_dump(), user_id=DEV_USER_ID)&lt;code&gt;** — &lt;/code&gt;model_dump()&lt;code&gt; turns the validated object into a dict, &lt;/code&gt;**` unpacks it into the model, and I tack on the owner. [That's the one line Phase 3 will change.]&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;add&lt;/code&gt; → &lt;code&gt;commit&lt;/code&gt; → &lt;code&gt;refresh&lt;/code&gt;&lt;/strong&gt; — stage it, write it, then re-read it so the DB-generated &lt;code&gt;id&lt;/code&gt;/timestamps get populated on my object before I return it.&lt;/li&gt;
&lt;/ul&gt;

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

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

&lt;h2&gt;
  
  
  Step 4: GET — list them all, and fetch one [meet the 404]
&lt;/h2&gt;

&lt;p&gt;Two reads, two conventions: &lt;code&gt;GET /expenses&lt;/code&gt; returns a list; &lt;code&gt;GET /expenses/{id}&lt;/code&gt; returns one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HTTPException&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;select&lt;/span&gt;


&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/expenses&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ExpenseRead&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;list_expenses&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/expenses/{expense_id}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ExpenseRead&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_expense&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expense_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;expense&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expense_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;expense&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Expense not found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;expense&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;db.scalars(select(Expense)).all()&lt;/code&gt;&lt;/strong&gt; is the same SQLAlchemy 2.0 read from Phase 1, just returning the whole list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;db.get(Expense, id)&lt;/code&gt;&lt;/strong&gt; is the shortcut for "fetch one by primary key" — returns the object, or &lt;code&gt;None&lt;/code&gt; if there's no such id.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;raise HTTPException(404)&lt;/code&gt;&lt;/strong&gt; is the important habit. If I let &lt;code&gt;None&lt;/code&gt; sail through, the code crashes trying to serialize nothing → a &lt;code&gt;500&lt;/code&gt;, which wrongly implies &lt;em&gt;I&lt;/em&gt; broke. Instead I deliberately raise a &lt;strong&gt;404 Not Found&lt;/strong&gt;: "your request was fine, that thing just isn't here." &lt;code&gt;raise&lt;/code&gt; [not &lt;code&gt;return&lt;/code&gt;] stops the function dead and sends the error.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And here's the coolest thing I learned this phase, entirely by fat-fingering the docs:&lt;/p&gt;

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

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

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #4:&lt;/strong&gt; My list endpoint gave a "cannot fetch" until I noticed I'd dropped the leading &lt;code&gt;/&lt;/code&gt; 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 5: PATCH — change only what changed
&lt;/h2&gt;

&lt;p&gt;Update has two flavors, and picking the right one matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PUT&lt;/strong&gt; = "replace the whole resource." The client must resend &lt;em&gt;every&lt;/em&gt; field; anything left out gets wiped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PATCH&lt;/strong&gt; = "apply a partial change." Send only the fields you're changing; everything else stays.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExpenseUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;spent_on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/expenses/{expense_id}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ExpenseRead&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;update_expense&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expense_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ExpenseUpdate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;expense&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expense_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;expense&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Expense not found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;update_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exclude_unset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;update_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="nf"&gt;setattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expense&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expense&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;expense&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #5:&lt;/strong&gt; Two things bit me here. First, another &lt;code&gt;500&lt;/code&gt;, because I typed &lt;code&gt;details=&lt;/code&gt; instead of &lt;code&gt;detail=&lt;/code&gt; in &lt;code&gt;HTTPException&lt;/code&gt; — a bad keyword makes the exception itself blow up. Second, and sneakier: when I hit &lt;strong&gt;Execute&lt;/strong&gt; in &lt;code&gt;/docs&lt;/code&gt; without editing the body, my &lt;code&gt;amount&lt;/code&gt; came back as some huge float. Turns out &lt;strong&gt;Swagger pre-fills the request body with placeholder sample values&lt;/strong&gt; — clicking Execute doesn't send "nothing," it sends &lt;em&gt;those placeholders&lt;/em&gt;. So PATCH dutifully saved a giant number. [Combined with SQLite storing &lt;code&gt;Numeric&lt;/code&gt; 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. &lt;code&gt;{"amount": 99.99}&lt;/code&gt;. &lt;code&gt;exclude_unset&lt;/code&gt; can't exclude what Swagger auto-filled for you.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 6: DELETE — remove one, return nothing
&lt;/h2&gt;

&lt;p&gt;Last verb, and it introduces one more status code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/expenses/{expense_id}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTP_204_NO_CONTENT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;delete_expense&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expense_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;expense&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expense_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;expense&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Expense not found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expense&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;204 No Content&lt;/strong&gt; is the convention for a successful delete: "it worked, and there's deliberately nothing to send back." That's why this endpoint has no &lt;code&gt;response_model&lt;/code&gt; — there's no resource left to shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;db.delete()&lt;/code&gt; + &lt;code&gt;db.commit()&lt;/code&gt;&lt;/strong&gt; is the destructive bit. Once committed, the row is &lt;em&gt;gone&lt;/em&gt; — 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  One non-code head-scratcher [worth writing down]
&lt;/h2&gt;

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

&lt;h2&gt;
  
  
  Stuff I want to remember [the honest takeaways]
&lt;/h2&gt;

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

&lt;p&gt;Next up: Phase 3, Authentication — where that hardcoded &lt;code&gt;DEV_USER_ID&lt;/code&gt; 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.&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>python</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>My App Can Finally Remember Stuff! (Phase 1 — The Database)</title>
      <dc:creator>Azhar Alvi </dc:creator>
      <pubDate>Wed, 22 Jul 2026 09:23:28 +0000</pubDate>
      <link>https://dev.to/silentcarry/my-app-can-finally-remember-stuff-phase-1-the-database-58lg</link>
      <guid>https://dev.to/silentcarry/my-app-can-finally-remember-stuff-phase-1-the-database-58lg</guid>
      <description>&lt;p&gt;&lt;strong&gt;Design it on paper, model it in Python, and NEVER touch the schema by hand.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So Phase 0 got my little FastAPI app running. One route, one JSON response, a lot of pride. But here's the thing nobody warns you about: that app had the memory of a goldfish. Restart it and… nothing. No users, no data, no clue who anyone is.&lt;/p&gt;

&lt;p&gt;Phase 1 fixes that. This is where the app gets an actual database — a place to &lt;em&gt;remember&lt;/em&gt; things. And honestly? This is where "I'm following a tutorial" started turning into "oh, I kind of get how backends work now." I went in knowing zero SQL. Came out having designed tables, built models, run a real migration, and read/written actual data. Let me dump what I learned [and the parts that tripped me up, because there were a few].&lt;/p&gt;

&lt;p&gt;The Structure is as follows. Lets call it PHASE 1 — The Data Layer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Get the right tools installed in the right place (SQLAlchemy + Alembic)&lt;/li&gt;
&lt;li&gt;Design the tables on paper BEFORE writing any code&lt;/li&gt;
&lt;li&gt;Turn that design into Python models&lt;/li&gt;
&lt;li&gt;Wire up the database connection&lt;/li&gt;
&lt;li&gt;Set up Alembic and run my first migration&lt;/li&gt;
&lt;li&gt;Actually write and read a row [the payoff!]&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhechw7ncijflrrioakqn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhechw7ncijflrrioakqn.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  First, the two new toys
&lt;/h2&gt;

&lt;p&gt;Two libraries do the heavy lifting here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SQLAlchemy&lt;/strong&gt; — the ORM. Fancy word, simple job: it lets me talk to the database in Python instead of writing raw SQL. I write &lt;code&gt;session.add(user)&lt;/code&gt;, it writes the &lt;code&gt;INSERT&lt;/code&gt; for me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alembic&lt;/strong&gt; — the migration tool. Think of it as git, but for the &lt;em&gt;shape&lt;/em&gt; of your database. Every time I change the table structure, it saves that change as a numbered file I can replay or roll back.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;source &lt;/span&gt;venv/Scripts/activate      &lt;span class="c"&gt;# Windows/Git Bash = Scripts/, not bin/ [learned this the hard way in Phase 0]&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;sqlalchemy alembic
pip freeze &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #1:&lt;/strong&gt; I first installed these in a random throwaway folder, then wondered why my real project couldn't see them. Turns out a virtual environment is per-folder — packages installed in one venv are invisible to another. Reinstalled in the actual project. Lesson: check for &lt;code&gt;(venv)&lt;/code&gt; in your prompt and make sure it's the &lt;em&gt;right&lt;/em&gt; one before you install anything.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 1: Draw it on paper (yes, really)
&lt;/h2&gt;

&lt;p&gt;Before touching code, I sketched two boxes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;USERS                              EXPENSES
  id       (primary key)             id         (primary key)
  name                               amount     (money)
  email    (unique)                  description
  created_at / updated_at            spent_on   (the date)
                                     user_id  --&amp;gt; points to USERS.id
                                     created_at / updated_at
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two ideas that made everything click:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;primary key&lt;/strong&gt; (&lt;code&gt;id&lt;/code&gt;) is just a unique, auto-numbered tag for each row. Names repeat, IDs don't.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;foreign key&lt;/strong&gt; (&lt;code&gt;user_id&lt;/code&gt;) is a column that stores another table's id. That's the whole magic of relational databases — Azhar's expenses all carry his &lt;code&gt;user_id&lt;/code&gt;, so the DB always knows whose lunch was whose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sounds obvious now. Was NOT obvious an hour earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Turn the drawing into models
&lt;/h2&gt;

&lt;p&gt;This is &lt;code&gt;models.py&lt;/code&gt; — basically my paper sketch, but in Python. One class = one table.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;MetaData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;func&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy.orm&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DeclarativeBase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;relationship&lt;/span&gt;


&lt;span class="n"&gt;NAMING_CONVENTION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ix&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ix_%(column_0_label)s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uq_%(table_name)s_%(column_0_name)s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ck&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ck_%(table_name)s_%(constraint_name)s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pk_%(table_name)s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DeclarativeBase&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MetaData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;naming_convention&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;NAMING_CONVENTION&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;__tablename__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;primary_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;server_default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;server_default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;onupdate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;expenses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Expense&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;relationship&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;back_populates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;__tablename__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expenses&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;primary_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;spent_on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;users.id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;server_default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;server_default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;onupdate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;relationship&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;back_populates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expenses&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things I picked up here that felt like "real developer" moments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Money is NEVER a float.&lt;/strong&gt; Ever. Floats can't hold &lt;code&gt;0.10 + 0.20&lt;/code&gt; exactly [it comes out as 0.30000000000000004, I'm not kidding], so over enough expenses your totals drift. Store it as &lt;code&gt;Numeric(10, 2)&lt;/code&gt; → Python &lt;code&gt;Decimal&lt;/code&gt;. Exact, always.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every table gets &lt;code&gt;created_at&lt;/code&gt; and &lt;code&gt;updated_at&lt;/code&gt;.&lt;/strong&gt; You will always, always want to know when something was made or changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The type annotation controls nullability.&lt;/strong&gt; &lt;code&gt;Mapped[str]&lt;/code&gt; = required. &lt;code&gt;Mapped[str | None]&lt;/code&gt; = optional. Neat.&lt;/li&gt;
&lt;li&gt;Those two &lt;code&gt;relationship(...)&lt;/code&gt; lines aren't columns — they're just Python convenience so I can write &lt;code&gt;user.expenses&lt;/code&gt; and get a list back without writing a query. The actual link is still the &lt;code&gt;user_id&lt;/code&gt; foreign key.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #2:&lt;/strong&gt; I originally named the expense date column &lt;code&gt;date&lt;/code&gt;. Bad idea — naming a column the same as its Python type (&lt;code&gt;date: Mapped[date]&lt;/code&gt;) confuses everything and breaks type inference. Renamed it &lt;code&gt;spent_on&lt;/code&gt;. Moral: don't name your stuff after builtins.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 3: The connection [kept separate on purpose]
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;database.py&lt;/code&gt; — this is &lt;em&gt;how&lt;/em&gt; to connect, deliberately kept apart from &lt;code&gt;models.py&lt;/code&gt; [which is &lt;em&gt;what&lt;/em&gt; the tables are]:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;create_engine&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy.orm&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sessionmaker&lt;/span&gt;

&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sqlite:///./expenses.db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;connect_args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;check_same_thread&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sqlite&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;echo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;connect_args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;connect_args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;SessionLocal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sessionmaker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;autoflush&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;autocommit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why the &lt;code&gt;os.getenv&lt;/code&gt; dance instead of just hardcoding the path? Because future-me deploying to a real Postgres database will just set an environment variable and NOT have to change a single line of code. Same reason secrets live in &lt;code&gt;.env&lt;/code&gt;. One source of truth. Also — &lt;code&gt;echo=True&lt;/code&gt; prints every SQL statement the ORM generates, which is weirdly satisfying to watch.&lt;/p&gt;

&lt;p&gt;Oh, and before creating any of this I added &lt;code&gt;*.db&lt;/code&gt; and &lt;code&gt;*.sqlite3&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt;. The database file is local junk data — it has no business in my repo. [And yeah, same "before it exists" trick I used for &lt;code&gt;.env&lt;/code&gt; — git can't accidentally track what it was already told to ignore.]&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Alembic and my first migration
&lt;/h2&gt;

&lt;p&gt;Here's the part I was low-key scared of, and it turned out to be the coolest bit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;alembic init alembic     &lt;span class="c"&gt;# creates the migration setup&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I pointed Alembic at my project in &lt;code&gt;alembic/env.py&lt;/code&gt; — told it where my models live and where the database is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;database&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Base&lt;/span&gt;

&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_main_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sqlalchemy.url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;target_metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the important bit: Alembic's autogenerate works by &lt;em&gt;comparing&lt;/em&gt; my models to the actual database and writing the difference. &lt;code&gt;target_metadata&lt;/code&gt; is how it "sees" my models. Without it, it detects nothing.&lt;/p&gt;

&lt;p&gt;I also added that &lt;code&gt;NAMING_CONVENTION&lt;/code&gt; thing (up in &lt;code&gt;models.py&lt;/code&gt;) BEFORE generating anything. Why bother? Because otherwise the database invents random names for your constraints/indexes — and SQLite often gives them no name at all. Then a future migration that needs to change one has nothing to grab onto. Set the convention early, and everything gets clean, predictable names like &lt;code&gt;fk_expenses_user_id_users&lt;/code&gt;. Set-it-and-forget-it.&lt;/p&gt;

&lt;p&gt;Then the magic command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;alembic revision &lt;span class="nt"&gt;--autogenerate&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"create users and expenses tables"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And it &lt;em&gt;wrote the migration for me&lt;/em&gt; — two &lt;code&gt;create_table&lt;/code&gt; calls, the foreign key, the indexes, plus a matching &lt;code&gt;downgrade()&lt;/code&gt; that undoes it all. I read the whole file before running it [apparently this is a habit real teams enforce — autogenerate isn't perfect, so you always eyeball it first]. Looked right. So:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;alembic upgrade &lt;span class="nb"&gt;head&lt;/span&gt;     &lt;span class="c"&gt;# actually builds the tables&lt;/span&gt;
alembic current          &lt;span class="c"&gt;# shows my revision with (head) = up to date&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watching the real &lt;code&gt;CREATE TABLE&lt;/code&gt; SQL scroll past because of &lt;code&gt;echo=True&lt;/code&gt;? Chef's kiss. That was the "oh, THIS is what an ORM does" moment.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #3:&lt;/strong&gt; My &lt;code&gt;-m&lt;/code&gt; message accidentally said "expense" (singular), so the migration &lt;em&gt;filename&lt;/em&gt; said expense too — and I panicked thinking the tables were wrong. They weren't. The &lt;code&gt;-m&lt;/code&gt; text is just a label; the real table names come from &lt;code&gt;__tablename__&lt;/code&gt;. Totally cosmetic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha #4:&lt;/strong&gt; When I pasted my code into an email, Gmail helpfully turned &lt;code&gt;users.id&lt;/code&gt; into a clickable link &lt;code&gt;http://users.id/&lt;/code&gt;. Nearly gave me a heart attack. It's just email being email — the real file was fine. Check your code in the editor, not in email.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 5: The payoff — write a row, read it back
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;select&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;database&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SessionLocal&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Expense&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;SessionLocal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Azhar&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;azhar@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;            &lt;span class="c1"&gt;# NOW it's in the DB, and user.id gets assigned
&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Expense&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;12.50&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Lunch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;spent_on&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;today&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;fetched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetched&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fetched&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fetched&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expenses&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And it printed my user and their lunch. My app remembers things now. 🎉&lt;/p&gt;

&lt;p&gt;Couple of things worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Nothing actually hits the database until &lt;code&gt;commit()&lt;/code&gt;. That's also &lt;em&gt;when&lt;/em&gt; the primary key gets assigned — which is exactly why I commit the user before the expense that needs its &lt;code&gt;id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Decimal("12.50")&lt;/code&gt; uses quotes on purpose. &lt;code&gt;Decimal(12.50)&lt;/code&gt; [no quotes] would drag the float imprecision back in. Little detail, big deal for money.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;select(User)&lt;/code&gt; with &lt;code&gt;session.scalars(...)&lt;/code&gt; is the modern SQLAlchemy 2.0 way. If you see &lt;code&gt;session.query(...)&lt;/code&gt; in older tutorials, that's the legacy style.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #5:&lt;/strong&gt; Ran the script a second time and it blew up with an &lt;code&gt;IntegrityError&lt;/code&gt; on the email. That's not a bug — it's my &lt;code&gt;unique&lt;/code&gt; constraint literally doing its job and refusing a duplicate. Honestly kind of reassuring to see it work.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Stuff I want to remember [the honest takeaways]
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Design on paper first. The code is just the drawing, translated.&lt;/li&gt;
&lt;li&gt;Money is &lt;code&gt;Decimal&lt;/code&gt;/&lt;code&gt;Numeric&lt;/code&gt;, never float. Build it from a string.&lt;/li&gt;
&lt;li&gt;Give every table &lt;code&gt;created_at&lt;/code&gt; / &lt;code&gt;updated_at&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Never hardcode the database URL — read it from an env var.&lt;/li&gt;
&lt;li&gt;NEVER edit the database structure by hand. Models → migration, every single time.&lt;/li&gt;
&lt;li&gt;Always read an autogenerated migration before you run it.&lt;/li&gt;
&lt;li&gt;Set your naming convention before the first migration, not after.&lt;/li&gt;
&lt;li&gt;Virtual environments are per-project. Install in the right one. Freeze &lt;code&gt;requirements.txt&lt;/code&gt; after every install.&lt;/li&gt;
&lt;li&gt;Ignore &lt;code&gt;.db&lt;/code&gt; files and secrets &lt;em&gt;before&lt;/em&gt; they exist. And always commit your migration files — they're the schema's shared history.&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;echo=True&lt;/code&gt; on while learning so you can actually see the SQL. It demystifies everything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next up: Phase 2, where I turn all this into real API endpoints [POST an expense, GET the list, the whole CRUD gang]. If Phase 1 gave the app a memory, Phase 2 gives it a mouth. See you there.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Phase 0 : Building My First FastAPI App: Setup, Git, and Secrets Done Right</title>
      <dc:creator>Azhar Alvi </dc:creator>
      <pubDate>Thu, 16 Jul 2026 18:57:38 +0000</pubDate>
      <link>https://dev.to/silentcarry/phase-0-building-my-first-fastapi-app-setup-git-and-secrets-done-right-38m6</link>
      <guid>https://dev.to/silentcarry/phase-0-building-my-first-fastapi-app-setup-git-and-secrets-done-right-38m6</guid>
      <description>&lt;p&gt;&lt;strong&gt;Tagline: Commit small, commit often, write real messages.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Creating a hello-world app sounds easy — and it is. But doing it &lt;em&gt;the right way&lt;/em&gt;, with proper git hygiene, isolated environments, and secrets handled safely from day one? That takes a bit more care. Here's what I actually did, step by step, and why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Index
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Introduction&lt;/li&gt;
&lt;li&gt;Structure of this log&lt;/li&gt;
&lt;li&gt;
Step 1: Project Foundations

&lt;ul&gt;
&lt;li&gt;Create the folder&lt;/li&gt;
&lt;li&gt;Initialize git first&lt;/li&gt;
&lt;li&gt;Create &lt;code&gt;.gitignore&lt;/code&gt; before installing anything&lt;/li&gt;
&lt;li&gt;Create and activate the virtual environment&lt;/li&gt;
&lt;li&gt;Install packages + freeze requirements&lt;/li&gt;
&lt;li&gt;First commit&lt;/li&gt;
&lt;li&gt;Connect to GitHub&lt;/li&gt;
&lt;li&gt;The repeatable loop&lt;/li&gt;
&lt;li&gt;The commit flow, in isolation&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
Step 2: Getting FastAPI Running Locally

&lt;ul&gt;
&lt;li&gt;Ensure the virtual environment is active&lt;/li&gt;
&lt;li&gt;Install FastAPI and Uvicorn&lt;/li&gt;
&lt;li&gt;Create the app file&lt;/li&gt;
&lt;li&gt;Start the server&lt;/li&gt;
&lt;li&gt;Check the automatic docs&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
Step 3: Secrets — the &lt;code&gt;.env&lt;/code&gt; File

&lt;ul&gt;
&lt;li&gt;Create &lt;code&gt;.env&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Confirm it's actually ignored&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Step 4: Start This Build Log&lt;/li&gt;
&lt;li&gt;Key Habits to Keep&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;I started learning backend dev properly this week, and today I got a hello-world FastAPI app running locally. Nothing fancy — one route, one JSON response — but I wanted to write down what actually happened along the way, because "install a package and run a command" always looks simpler in hindsight than it feels in the moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Structure of this log
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Create project folder → init git → &lt;code&gt;.gitignore&lt;/code&gt; → virtual environment&lt;/li&gt;
&lt;li&gt;Connect to a GitHub repo&lt;/li&gt;
&lt;li&gt;Build and run a FastAPI hello-world app, explore &lt;code&gt;/docs&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Set up a &lt;code&gt;.env&lt;/code&gt; file for secrets and confirm it's gitignored&lt;/li&gt;
&lt;li&gt;Start this build log&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Step 1: Project Foundations
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Create the folder
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;my-project-1
&lt;span class="nb"&gt;cd &lt;/span&gt;my-project-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Initialize git &lt;em&gt;first&lt;/em&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why first?&lt;/strong&gt; So every file created from this point on is tracked from day one — no risk of forgetting to init later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Create &lt;code&gt;.gitignore&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; installing anything
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .gitignore &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
venv/
__pycache__/
.env
.DS_Store
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why before the virtual environment?&lt;/strong&gt; If you create the venv first, git could briefly see hundreds of library files before you've excluded them. Doing this first avoids ever accidentally staging things you don't want tracked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How this command works:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cat&lt;/code&gt; — normally reads and displays file content, but here it's not reading a file — it's about to receive typed input instead.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;gt;&lt;/code&gt; — redirect operator. Instead of printing to the screen, the output is written into a file. Since &lt;code&gt;.gitignore&lt;/code&gt; doesn't exist yet, this creates it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;&amp;lt; 'EOF'&lt;/code&gt; — a &lt;strong&gt;heredoc&lt;/strong&gt;. It tells bash: "don't wait for a real file — treat everything typed next, up until a line that says exactly &lt;code&gt;EOF&lt;/code&gt;, as the input text."&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;EOF&lt;/code&gt; (closing) — signals "stop, that's the end of the input."&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; &lt;code&gt;EOF&lt;/code&gt; isn't a special keyword — it's just a convention. You could use &lt;code&gt;BANANA&lt;/code&gt; instead, and it would work identically: bash treats everything as raw text until it sees a &lt;em&gt;line&lt;/em&gt; containing exactly that word. &lt;code&gt;EOF&lt;/code&gt; ("End Of File") is used everywhere because it instantly signals intent to anyone reading the script.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; This heredoc syntax is bash-specific. PowerShell uses a different syntax (&lt;code&gt;@" ... "@ | Out-File&lt;/code&gt;) — match the syntax to the shell you're actually in.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Create and activate the virtual environment
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; venv venv
&lt;span class="nb"&gt;source &lt;/span&gt;venv/Scripts/activate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this does:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;python -m venv venv&lt;/code&gt; — runs Python's built-in &lt;code&gt;venv&lt;/code&gt; module, creating a folder (&lt;code&gt;venv/&lt;/code&gt;) containing an isolated copy of Python and its own package directory.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;source venv/Scripts/activate&lt;/code&gt; — activates it, redirecting your terminal's &lt;code&gt;python&lt;/code&gt;/&lt;code&gt;pip&lt;/code&gt; to point inside &lt;code&gt;venv/&lt;/code&gt; instead of your system-wide install. You'll know it worked when &lt;code&gt;(venv)&lt;/code&gt; appears in your prompt.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The folder is named &lt;code&gt;Scripts/&lt;/code&gt; on Windows and &lt;code&gt;bin/&lt;/code&gt; on Linux/macOS. If &lt;code&gt;source venv/bin/activate&lt;/code&gt; fails with "No such file or directory," check which one actually exists with &lt;code&gt;ls venv&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Install packages + freeze requirements
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;requests
pip freeze &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this matters:&lt;/strong&gt; every time you install something new, freeze it into &lt;code&gt;requirements.txt&lt;/code&gt;. This makes the exact dependency set shareable — anyone (including future-you) can recreate your environment with &lt;code&gt;pip install -r requirements.txt&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  First commit
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;touch &lt;/span&gt;README.md main.py      &lt;span class="c"&gt;# or: New-Item README.md, main.py   (PowerShell)&lt;/span&gt;
git add &lt;span class="nb"&gt;.&lt;/span&gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Initial project setup"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What makes a good commit message:&lt;/strong&gt; it describes &lt;em&gt;what changed and why&lt;/em&gt; — not just "update" or "fix stuff."&lt;/p&gt;

&lt;h3&gt;
  
  
  Connect to GitHub
&lt;/h3&gt;

&lt;p&gt;Create a new, empty repository on GitHub first, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git remote add origin &amp;lt;your-repo-url&amp;gt;
git branch &lt;span class="nt"&gt;-M&lt;/span&gt; main
git push &lt;span class="nt"&gt;-u&lt;/span&gt; origin main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Breaking this down:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git remote add origin &amp;lt;url&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Registers a remote connection, nicknamed &lt;code&gt;origin&lt;/code&gt; (convention), pointing at your GitHub repo's URL. No data moves yet — this just registers the address.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git branch -M main&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Renames your current branch to &lt;code&gt;main&lt;/code&gt; (forcing it even if a branch with that name exists), matching what GitHub expects by default.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git push -u origin main&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Uploads your commits to GitHub for the first time. &lt;code&gt;-u&lt;/code&gt; sets up tracking between local &lt;code&gt;main&lt;/code&gt; and &lt;code&gt;origin/main&lt;/code&gt;, so every push after this can just be &lt;code&gt;git push&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Run this sequence once per project&lt;/strong&gt; — right at the start, connecting a fresh local repo to a fresh GitHub repo.&lt;/p&gt;

&lt;h3&gt;
  
  
  The repeatable loop (used for every session after this)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;source &lt;/span&gt;venv/Scripts/activate
&lt;span class="c"&gt;# ... work, install packages ...&lt;/span&gt;
pip freeze &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; requirements.txt
git add &lt;span class="nb"&gt;.&lt;/span&gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"clear description of change"&lt;/span&gt;
git push
deactivate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The commit flow, in isolation
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git status                          &lt;span class="c"&gt;# check what changed&lt;/span&gt;
git add .gitignore                  &lt;span class="c"&gt;# stage the change&lt;/span&gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"clear description"&lt;/span&gt;   &lt;span class="c"&gt;# commit with a real message&lt;/span&gt;
git push                            &lt;span class="c"&gt;# push to GitHub&lt;/span&gt;
git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;                &lt;span class="c"&gt;# sanity check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 2: Getting FastAPI Running Locally
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Ensure the virtual environment is active
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;source &lt;/span&gt;venv/Scripts/activate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Install FastAPI and Uvicorn
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;fastapi uvicorn
pip freeze &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;FastAPI&lt;/strong&gt; — lets you define API routes/endpoints in Python. It doesn't serve requests on its own — it needs an &lt;strong&gt;ASGI&lt;/strong&gt; (Asynchronous Server Gateway Interface) server to actually run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uvicorn&lt;/strong&gt; — a lightweight ASGI server that runs your app and listens for HTTP requests.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Create the app file
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;code &lt;span class="nb"&gt;.&lt;/span&gt;   &lt;span class="c"&gt;# opens the current folder in VS Code&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Structure of &lt;code&gt;main.py&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Import &lt;code&gt;FastAPI&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Create an &lt;strong&gt;application instance&lt;/strong&gt; (&lt;code&gt;app = FastAPI()&lt;/code&gt;) for Uvicorn to run&lt;/li&gt;
&lt;li&gt;Use a &lt;strong&gt;decorator&lt;/strong&gt; (&lt;code&gt;@app.get("/")&lt;/code&gt;) so that a GET request to the root URL runs the function below it&lt;/li&gt;
&lt;li&gt;Define the function to handle the request&lt;/li&gt;
&lt;li&gt;Return the response (FastAPI auto-converts a Python dict to JSON)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;read_root&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hello, World!&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Start the server
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uvicorn main:app &lt;span class="nt"&gt;--reload&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;main:app&lt;/code&gt; — tells Uvicorn to look inside &lt;code&gt;main.py&lt;/code&gt; for an object named &lt;code&gt;app&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--reload&lt;/code&gt; — automatically restarts the server whenever you save a code change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Visit &lt;code&gt;http://127.0.0.1:8000&lt;/code&gt; to see the JSON response.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check the automatic docs
&lt;/h3&gt;

&lt;p&gt;Visit &lt;code&gt;http://127.0.0.1:8000/docs&lt;/code&gt; — FastAPI auto-generates a full interactive API explorer (Swagger UI / OpenAPI spec), built entirely from your route definitions. No docs code required.&lt;/p&gt;

&lt;p&gt;A read-only alternative view is available at &lt;code&gt;/redoc&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Secrets — the &lt;code&gt;.env&lt;/code&gt; File
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Create &lt;code&gt;.env&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
APP_SECRET=super-secret-value-123
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Confirm it's actually ignored — don't just assume
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git status                 &lt;span class="c"&gt;# .env should not appear anywhere in the output&lt;/span&gt;
git check-ignore &lt;span class="nt"&gt;-v&lt;/span&gt; .env   &lt;span class="c"&gt;# the definitive test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;git check-ignore -v&lt;/code&gt; reports exactly which line in which file is causing the file to be ignored, e.g.:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;.&lt;span class="n"&gt;gitignore&lt;/span&gt;:&lt;span class="m"&gt;7&lt;/span&gt;:.&lt;span class="n"&gt;env&lt;/span&gt;    .&lt;span class="n"&gt;env&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; &lt;code&gt;.gitignore&lt;/code&gt; only prevents git from tracking files it &lt;em&gt;hasn't already started tracking&lt;/em&gt;. If a secret was committed before being added to &lt;code&gt;.gitignore&lt;/code&gt;, it will still exist in your git history. Setting up the ignore rule &lt;strong&gt;before&lt;/strong&gt; the first &lt;code&gt;git add&lt;/code&gt; — which is what happened here — avoids this entirely.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 4: Start This Build Log
&lt;/h2&gt;

&lt;p&gt;Keep two documents, for two different audiences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;BUILD_LOG.md&lt;/code&gt;&lt;/strong&gt; (this file) — chronological, for future-me. What I did, what broke, what I learned. Terse is fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;README.md&lt;/code&gt;&lt;/strong&gt; — for other people (or future-me pretending to be someone else). What the project is and how to run it — a snapshot of current state, not a diary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The most useful log entries capture the &lt;em&gt;why&lt;/em&gt;, not just the &lt;em&gt;what&lt;/em&gt; — especially for snags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## 2026-07-16&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Set up project folder, git repo, and Python virtual environment
&lt;span class="p"&gt;-&lt;/span&gt; Installed FastAPI + Uvicorn, built a hello-world app with a single GET / route
&lt;span class="p"&gt;-&lt;/span&gt; Confirmed it runs locally and explored the auto-generated /docs (Swagger UI)
&lt;span class="p"&gt;-&lt;/span&gt; Set up a .env file for secrets and confirmed it's properly gitignored
  (learned: .gitignore only blocks files git hasn't tracked yet — order matters)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commit it the same way as everything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add BUILD_LOG.md
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Start build log with first entry"&lt;/span&gt;
git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Key Habits to Keep
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Commit small, commit often, write real messages&lt;/strong&gt; — a message should answer "what changed and why," not just "update."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.gitignore&lt;/code&gt; before &lt;code&gt;git add&lt;/code&gt;&lt;/strong&gt;, always — order of operations is what actually protects secrets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freeze requirements on every install&lt;/strong&gt;, not just at the end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log the "why" and the snags&lt;/strong&gt;, not just the "what" — that's the part future-you will actually forget.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>beginners</category>
      <category>git</category>
      <category>python</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
