Food Tracker (FastAPI)

A Complete 12-Chapter Async-Native Course

Topics covered:
Async-native FastAPI · Pydantic + SQLAlchemy · async barcode lookup
Vanilla-JS scanning · structured validation errors · expiry alerts
Native search · marking items used · concurrent recipe lookup
Background tasks · deployment

Capstone: one complete, working app, chapter by chapter
Exercises: 36 hands-on scenarios with worked solutions
Format: A4 · Dark-theme code examples

The fourth and final course in the Food Tracker Quartet — see also
Django, React + Express, and React + Firebase editions
Philip Osztromok · Generated with Claude

Table of Contents

  1. Project Overview & FastAPI Project Setup
  2. Data Modeling with Pydantic & SQLAlchemy
  3. Barcode Lookup: Integrating Open Food Facts
  4. Camera-Based Barcode Scanning (Frontend)
  5. Building the Add-Item Flow
  6. Expiry Alerts
  7. Item History & Live Search-as-You-Type
  8. Marking Items Used
  9. Recipe Lookup with TheMealDB
  10. Async Patterns & Background Tasks
  11. Deployment
  12. Capstone: A Complete, Working Food Tracker
Chapter 1 of 12

Project Overview & FastAPI Project Setup

Food Tracker (FastAPI)

Chapter 1 · Project Overview & FastAPI Project Setup

This is the fourth and final course in a deliberate quartet — Food Tracker (Django), Food Tracker (React + Express), and Food Tracker (React + Firebase) are its siblings, each building the exact same app in a genuinely different architecture. This course closes the set with the one variant that isn't paired with React at all: a plain, framework-free vanilla-JS frontend, keeping every chapter's attention on FastAPI and its backend itself.

What the App Actually Does

Before any architecture talk, the shared spec every course in the quartet builds toward:

  • Scan a barcode with a phone or webcam camera, look it up against Open Food Facts (free, open, no API key) to fetch the product's name and details automatically.
  • Record a use-by date for the item, and see it flagged once it's expiring soon.
  • Keep a full history of every item ever added — some still active with a real expiry date, some already marked used with no expiry date at all — searchable in real time as you type, so re-adding something you've bought before is fast.
  • Look up recipes via TheMealDB (also free, no key) that use ingredients close to expiring.

A weekly meal planner is explicitly out of scope for all four courses — named future work, not something any of them will build.

Why Vanilla JS, Not a Fourth React Course

Both React + Express and React + Firebase already exist in this quartet, each demonstrating React against a genuinely different backend. Pairing FastAPI with React a third time would mostly repeat frontend lessons this quartet has already taught twice over, diluting the actual point of this course: FastAPI's own backend design. A deliberately plain frontend — real HTML, real CSS, real fetch calls, no build step, no framework — keeps every chapter's focus exactly where it belongs.

Why FastAPI

Three concrete, genuine reasons, not just general popularity:

  • Async-native from the ground up. Route handlers are written as async def by default, with real, non-blocking I/O for API calls and database access — a different default than Django's own synchronous-first model.
  • Pydantic built in. Request and response shapes are declared as real Python classes with real type hints, validated automatically on every request — no separate serializer library to reach for.
  • Automatic interactive API documentation. A fully working, browsable API explorer at /docs, generated entirely from the route definitions and Pydantic models already written for validation — nothing extra to configure.
A real feature none of the other three courses get for free
Food Tracker (Django) would need Django REST Framework plus an add-on like drf-spectacular to get interactive API docs at all. Food Tracker (React + Express) has nothing built in for this — a tool like Swagger UI would need to be wired up manually, describing every route by hand. Food Tracker (React + Firebase)'s own Cloud Functions have no request/response schema tooling whatsoever. FastAPI's own /docs page is genuinely automatic, generated directly from the same Pydantic models Chapter 2 writes for validation — one more course-specific "batteries included" moment, the same shape as Django's own free admin panel, just for a completely different battery.

Reusing an Already-Established Project Structure

This site's own Website Rebuild with FastAPI course already worked out a real, proven FastAPI project layout — this course reuses that convention rather than inventing a new one:

food-tracker-fastapi/ ├── main.py # FastAPI app instance, route registration ├── models.py # SQLAlchemy models (Chapter 2) ├── schemas.py # Pydantic models (Chapter 2) ├── database.py # SQLAlchemy engine/session setup ├── routers/ │ ├── items.py │ ├── lookup.py │ └── recipes.py ├── static/ # the plain vanilla-JS frontend │ ├── index.html │ ├── app.js │ └── style.css └── requirements.txt

Installing and Running

python -m venv venv source venv/bin/activate # venv\Scripts\activate on Windows pip install fastapi uvicorn[standard] sqlalchemy python-dotenv uvicorn main:app --reload

A minimal main.py, confirming the setup works end to end before any real feature exists:

# main.py from fastapi import FastAPI from fastapi.staticfiles import StaticFiles app = FastAPI(title="Food Tracker") @app.get("/api/health") async def health(): return {"status": "ok"} app.mount("/", StaticFiles(directory="static", html=True), name="static")

StaticFiles(..., html=True) serves index.html automatically for the root path and any unmatched path — this course's own equivalent of the SPA-fallback route Food Tracker (React + Express) had to hand-write, given for free by a single constructor argument here, since there's no client-side router involved at all in a plain multi-page vanilla-JS app.

FastAPI itself is deliberately minimal
Unlike Django's own batteries-included model, FastAPI ships no ORM, no admin panel, and no built-in database layer at all — SQLAlchemy (Chapter 2), a real database driver, and every other piece are choices this course makes and assembles deliberately, not defaults FastAPI provides out of the box. FastAPI's own "batteries included" story is narrower and more specific: async request handling, validation, and documentation — genuinely excellent at exactly those three things, and honestly minimal everywhere else.
Visit /docs immediately, before writing a single frontend line
Once uvicorn main:app --reload is running, http://localhost:8000/docs already shows a working, interactive explorer for the /api/health route above — a genuinely useful way to test every route this course builds, directly in the browser, without needing the vanilla-JS frontend at all.

Where This Course Is Headed

Data modeling with Pydantic and SQLAlchemy next — two genuinely separate layers, one for request/response validation and one for the database — then barcode lookup, camera scanning, the add-item flow, expiry alerts, item history and search, marking items used, recipe lookup, async patterns and background tasks, deployment, and a capstone tying every chapter into one complete, working app.

Hands-On Exercises

Exercise 1

Explain why this course deliberately uses a plain vanilla-JS frontend instead of pairing FastAPI with React a third time in this quartet.

📄 View solution
Exercise 2

Explain why FastAPI's automatic /docs page is described as a genuine feature none of the other three sibling courses get for free, naming what each sibling would need instead.

📄 View solution
Exercise 3

Explain why StaticFiles(directory="static", html=True) is described as this course's own equivalent of Food Tracker (React + Express)'s own hand-written SPA-fallback route, and why main.py above still registers the /api/health route before mounting it — despite this course having no client-side router to worry about.

📄 View solution

Chapter 1 Quick Reference

  • The shared app — barcode scan (Open Food Facts) → expiry tracking → alerts → searchable history → recipe lookup (TheMealDB); no meal planner
  • Frontend: deliberately plain vanilla JS — no framework, no build step, keeping focus on FastAPI itself
  • Why FastAPI: async-native, Pydantic validation built in, automatic interactive docs at /docs
  • Real advantage: /docs is genuinely automatic — none of the three sibling courses get this for free
  • Honest limit: FastAPI itself ships no ORM, no admin, no database layer — assembled deliberately, not provided by default
  • Project structure: reused from Website Rebuild with FastAPI's own established convention
  • Next chapter: Data Modeling with Pydantic & SQLAlchemy
Chapter 2 of 12

Data Modeling with Pydantic & SQLAlchemy

Food Tracker (FastAPI)

Chapter 2 · Data Modeling with Pydantic & SQLAlchemy

Every course in this quartet stores the same shape of data. This course expresses it as two genuinely separate models — one for what actually lives in the database, one for what a request is allowed to send.

The SQLAlchemy Model: Storage

# models.py from sqlalchemy import Column, Integer, String, Date, DateTime, func from database import Base class Item(Base): __tablename__ = "items" id = Column(Integer, primary_key=True, index=True) name = Column(String, nullable=False) barcode = Column(String, nullable=True) category = Column(String, nullable=True) expiry_date = Column(Date, nullable=True) status = Column(String, nullable=False, default="active") added_at = Column(DateTime, server_default=func.now()) used_at = Column(DateTime, nullable=True)

The same design decisions every course in this quartet already made: expiry_date nullable, cleared rather than the row deleted once an item is used; added_at stamped by the database itself via server_default=func.now(), never trusted from client input.

The Pydantic Schemas: Request & Response Shape

# schemas.py from pydantic import BaseModel from datetime import date, datetime from typing import Optional class ItemCreate(BaseModel): name: str barcode: Optional[str] = None category: Optional[str] = None expiry_date: Optional[date] = None class ItemResponse(BaseModel): id: int name: str barcode: Optional[str] category: Optional[str] expiry_date: Optional[date] status: str added_at: datetime class Config: from_attributes = True # lets this schema read directly from a SQLAlchemy object

ItemCreate deliberately has no id, no status, and no added_at field at all — not because the client sends them and they're ignored, but because they genuinely don't exist as fields the schema will accept. A request body containing a status field is simply rejected as an unexpected field, before any route handler code runs.

A different validation model than this quartet's other courses
Food Tracker (React + Express)'s own add-item route had to hand-write real checks — if (!name || typeof name !== "string" || !name.trim()) — inside the route handler's own body, and its own finding-box named that server-side check as the app's real gate. Here, the equivalent gate is the ItemCreate class itself: declaring item: ItemCreate as a route parameter means FastAPI validates the incoming JSON against that schema automatically, rejecting anything that doesn't match with a structured 422 response — the route handler's own code never even runs for a malformed request. Django's ModelForm sits somewhere between these two: one class handling both storage and form validation together, rather than SQLAlchemy and Pydantic's clean two-layer split.

Database Setup

# database.py from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, declarative_base engine = create_engine("sqlite:///./foodtracker.db") SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() def get_db(): db = SessionLocal() try: yield db finally: db.close()

get_db is written as a generator specifically so FastAPI's own dependency injection can use it — every route that needs a database session declares db: Session = Depends(get_db) and gets one automatically opened and closed around that single request, without writing that setup/teardown logic in every route by hand.

A SQLAlchemy object and a Pydantic object are not the same thing
Returning an Item instance (the SQLAlchemy model) directly from a route declared to return ItemResponse only works because from_attributes = True tells Pydantic how to read attributes off a non-dict object. Without it, FastAPI would fail trying to serialize the SQLAlchemy object directly — a genuinely common early mistake, conflating "a Python object representing a database row" with "a Python object representing a validated API response," which look similar but are built and behave very differently.
Optional[date] = None reads exactly like the column it represents
Compare this to Food Tracker (React + Express)'s own need to explicitly write expiryDate || null before sending a request, specifically to avoid an empty string reaching the database where a true NULL was expected. Optional[date] = None expresses the same "not required, genuinely absent" idea directly in the schema's own type — there's no separate normalization step to remember, because the type itself only ever admits a real date or nothing at all.

Where This Course Is Headed

Barcode lookup next — a FastAPI endpoint proxying Open Food Facts, reusing this chapter's own Pydantic/SQLAlchemy split for the lookup cache.

Hands-On Exercises

Exercise 1

Explain why ItemCreate has no id, status, or added_at fields, and what happens to a request body that includes a status field anyway.

📄 View solution
Exercise 2

Explain the difference between this chapter's validation approach and Food Tracker (React + Express)'s own hand-written validation checks, specifically in terms of whether route handler code ever runs for a malformed request.

📄 View solution
Exercise 3

Explain what from_attributes = True actually does, and what would go wrong if a route tried to return a SQLAlchemy Item instance from a route declared to return ItemResponse without it.

📄 View solution

Chapter 2 Quick Reference

  • Two separate layers: Item (SQLAlchemy, storage) and ItemCreate/ItemResponse (Pydantic, request/response validation)
  • ItemCreate: only fields a client may set — no id, status, or added_at, by design, not by convention
  • Real advantage: a malformed request is rejected by Pydantic before route handler code ever runs, unlike the Express sibling's own hand-written checks
  • from_attributes = True: lets a Pydantic schema read directly from a SQLAlchemy object — required, not automatic
  • get_db(): a generator dependency FastAPI uses to open/close a database session per request
  • Optional[date] = None: the type itself expresses "genuinely absent," no separate normalization step needed
  • Next chapter: Barcode Lookup: Integrating Open Food Facts
Chapter 3 of 12

Barcode Lookup: Integrating Open Food Facts

Food Tracker (FastAPI)

Chapter 3 · Barcode Lookup: Integrating Open Food Facts

Every course in this quartet integrates Open Food Facts through a server the client trusts, not a direct browser call. Here, that means a genuinely async FastAPI route — the first place this course's own "async-native" framing from Chapter 1 becomes concrete rather than theoretical.

Why Proxy Through the Server At All

Open Food Facts needs no API key — there's no secret to hide, so this isn't a security question. The real reasons, the same ones every sibling course already reasoned through:

  • Caching. The same barcode gets scanned repeatedly — caching the result avoids re-querying for a product already looked up.
  • Consistency. Every client gets identical lookup behavior, defined in one place.
  • Future-proofing. If Open Food Facts' own API shape ever changes, only the server needs to change.

A Cache Model, Same Pattern as Chapter 2

# models.py (appended) class BarcodeCache(Base): __tablename__ = "barcode_cache" barcode = Column(String, primary_key=True) name = Column(String, nullable=True) category = Column(String, nullable=True) cached_at = Column(DateTime, server_default=func.now())

barcode as the primary key here, exactly as reasoned in every sibling course: one product lookup per barcode, distinct from Item, where the same barcode can legitimately appear across many separate purchases.

A Genuinely Async Lookup Route

# routers/lookup.py import httpx from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from database import get_db import models router = APIRouter() @router.get("/{barcode}") async def lookup_barcode(barcode: str, db: Session = Depends(get_db)): cached = db.query(models.BarcodeCache).filter_by(barcode=barcode).first() if cached: return cached async with httpx.AsyncClient() as client: try: response = await client.get( f"https://world.openfoodfacts.org/api/v2/product/{barcode}.json", timeout=5.0, ) except httpx.RequestError: raise HTTPException(status_code=502, detail="Open Food Facts is unreachable") data = response.json() if data.get("status") != 1: raise HTTPException(status_code=404, detail="Product not found") product = data.get("product", {}) entry = models.BarcodeCache( barcode=barcode, name=product.get("product_name"), category=(product.get("categories_tags") or [None])[0], ) db.add(entry) db.commit() db.refresh(entry) return entry
A real payoff of "async-native," not just a slogan
Food Tracker (Django)'s own equivalent view used the synchronous requests library — a genuinely reasonable choice there, since Django's own request-handling model is synchronous by default. This route uses httpx.AsyncClient specifically because it's await-able: while this one request is waiting on Open Food Facts to respond, FastAPI's event loop is free to keep handling other incoming requests on the same worker, rather than that worker sitting blocked and idle for the whole duration of the external call. This is the same non-blocking behavior Food Tracker (React + Express)'s own fetch-based route got for free from Node's single-threaded event loop — Chapter 1's own async-native claim, now doing real, measurable work rather than just describing FastAPI's own design philosophy.
Open Food Facts' data is genuinely inconsistent
A valid barcode can still return a product with a missing name or no category at all, since Open Food Facts is crowdsourced. product.get("product_name") and the defensive (... or [None])[0] above already expect that — data.get("status") == 1 only confirms a product record exists, not that it's complete. Chapter 5's own add-item flow has to handle a None name or category gracefully, the same honest limit every sibling course names in its own equivalent chapter.
HTTPException is a structured way to fail
raise HTTPException(status_code=404, detail="...") immediately stops the route and returns a proper JSON error response shaped like {"detail": "Product not found"} — no manually building and returning an error object the way Food Tracker (React + Express)'s own routes did with res.status(404).json({ error: ... }). FastAPI recognizes the exception type and handles the response formatting on its own.

Where This Course Is Headed

The camera-scanning frontend next — plain JavaScript, no framework, wiring a decoded barcode to this chapter's own lookup endpoint.

Hands-On Exercises

Exercise 1

Explain what "the event loop is free to keep handling other requests" concretely means during the await client.get(...) call, and why a synchronous requests call in Django's own equivalent view doesn't offer the same benefit.

📄 View solution
Exercise 2

Explain why barcode is the primary key in BarcodeCache but the same field is not a primary key in the Item table from Chapter 2.

📄 View solution
Exercise 3

Explain the difference between the 404 raised for data.get("status") != 1 and the 502 raised for httpx.RequestError — what real-world situation does each one actually represent?

📄 View solution

Chapter 3 Quick Reference

  • Why proxy: caching, consistency, future-proofing — not secrecy, no API key involved
  • BarcodeCache: keyed by barcode, same pattern as every sibling course's own cache table
  • httpx.AsyncClient: a genuinely non-blocking call — the event loop stays free to serve other requests while this one waits
  • Real contrast: Food Tracker (Django)'s own synchronous requests call blocks its worker for the same duration this route doesn't
  • HTTPException: FastAPI's own structured way to return an error response, no manual res.status().json() equivalent needed
  • Real gotcha: Open Food Facts data is crowdsourced and often incomplete — null fields are expected, not an error
  • Next chapter: Camera-Based Barcode Scanning (Frontend)
Chapter 4 of 12

Camera-Based Barcode Scanning (Frontend)

Food Tracker (FastAPI)

Chapter 4 · Camera-Based Barcode Scanning (Frontend)

This is the one chapter in this entire course with no FastAPI involvement at all — the same is true of every sibling course's own Chapter 4. What's genuinely different here is the frontend it's written against: plain JavaScript, no React, no hooks, no component lifecycle to lean on.

Requesting Camera Access

const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" }, });

facingMode: "environment" requests the rear camera — the same call every sibling course's own scanning chapter starts with, since decoding a barcode client-side has nothing to do with which framework, or lack of one, sits around it.

Decoding, Written as Plain Functions

The same BarcodeDetector-with-ZXing-fallback logic every sibling course uses, here with no useEffect, no useRef — just a module-level variable holding the active stream, and two exported functions:

// static/scanner.js let currentStream = null; async function startScanner(videoEl, onDetected) { currentStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" }, }); videoEl.srcObject = currentStream; await videoEl.play(); if ("BarcodeDetector" in window) { const detector = new BarcodeDetector({ formats: ["ean_13", "upc_a"] }); const scan = async () => { if (!currentStream) return; // stopScanner() already ran const barcodes = await detector.detect(videoEl); if (barcodes.length > 0) { onDetected(barcodes[0].rawValue); return; } requestAnimationFrame(scan); }; scan(); } else { // fall back to ZXing's BrowserMultiFormatReader here } } function stopScanner() { currentStream?.getTracks().forEach((track) => track.stop()); currentStream = null; }

Wiring It Into the App's Views

This app has no client-side router — Chapter 1's static-file setup serves one index.html, and separate "views" are plain <div> sections shown and hidden with a CSS class, toggled by JavaScript:

// static/app.js function showScanView() { hideAllViews(); document.getElementById("scan-view").classList.remove("hidden"); const video = document.getElementById("scanner-video"); startScanner(video, async (barcode) => { stopScanner(); const response = await fetch(`/api/lookup/${barcode}`); const data = await response.json(); showAddItemView(data); }); } document.getElementById("cancel-scan-btn").addEventListener("click", () => { stopScanner(); showHomeView(); });
A real cost of choosing no framework
Every one of this quartet's three React-based siblings gets camera cleanup automatically — a useEffect's own return function runs whenever the component unmounts, with no way to forget it once it's written. Vanilla JS has no equivalent lifecycle concept at all: there's no "unmount" event when a plain <div> gets hidden by a CSS class. stopScanner() has to be called explicitly, by name, at every single place a user could leave the scan view — a successful scan, a cancel button, even a back-button handler if one existed. Miss even one path and the camera silently keeps running, with nothing in the language or the DOM to catch the mistake. This is the honest, direct cost of Chapter 1's own "deliberately minimal, no framework" choice: fewer moving parts, but every lifecycle guarantee React provides for free here becomes the developer's own manual responsibility instead.
Every navigation path needs its own stopScanner() call
The example above calls stopScanner() in two places — once inside onDetected, once in the cancel button's handler — specifically because there are two distinct ways to leave the scan view. Adding a third way to leave later (a global nav menu, say) without also adding a third stopScanner() call would leave the camera running in the background, exactly the same underlying bug the React-based siblings' own cleanup functions exist to prevent automatically.
Desktop testing doesn't tell the whole story
A laptop webcam is a poor stand-in for a real phone camera — no autofocus hunting, none of the resolution constraints a phone actually has. Test on a real phone against an HTTPS URL before trusting that scanning genuinely works.

Where This Course Is Headed

The add-item flow next — combining this chapter's scan result with a manual-entry fallback, a POST endpoint, and Pydantic validation errors surfaced directly to the user.

Hands-On Exercises

Exercise 1

Explain why this course's own cleanup discipline is described as a real cost of choosing no framework, contrasting it directly with how the three React-based sibling courses handle the same cleanup problem.

📄 View solution
Exercise 2

Explain why showScanView's code calls stopScanner() in two separate places, and what would happen if a third way to leave the scan view were added without a matching third call.

📄 View solution
Exercise 3

Explain what the if (!currentStream) return; check inside the scan function actually guards against, and why it's necessary given that requestAnimationFrame keeps calling scan() repeatedly.

📄 View solution

Chapter 4 Quick Reference

  • Same decoding logic as every sibling course: facingMode: "environment", BarcodeDetector with a ZXing fallback
  • Written as plain functions: startScanner/stopScanner, a module-level variable instead of a React ref
  • Real cost of no framework: cleanup is manual and explicit at every navigation path — no automatic useEffect equivalent
  • Every exit path needs its own stopScanner() call — a successful scan, a cancel button, and any future navigation path added later
  • The if (!currentStream) return guard: stops a stale scan loop from continuing after stopScanner() already ran
  • Next chapter: Building the Add-Item Flow
Chapter 5 of 12

Building the Add-Item Flow

Food Tracker (FastAPI)

Chapter 5 · Building the Add-Item Flow

Chapter 4's scan flow hands off a lookup result — or nothing at all, if the scan misses or the product isn't found. This chapter is where that result, or a manually typed one, actually becomes a row in the database.

A Trivially Short Route

# routers/items.py @router.post("/", response_model=schemas.ItemResponse, status_code=201) def create_item(item: schemas.ItemCreate, db: Session = Depends(get_db)): db_item = models.Item(**item.model_dump()) db.add(db_item) db.commit() db.refresh(db_item) return db_item
Why there's almost nothing left to write here
Food Tracker (React + Express)'s own POST route began with real, hand-written validation — if (!name || typeof name !== "string" || ...) — because that route's own job was both validating and saving. This route's job is only saving. By the time create_item's own body starts executing, item is already a fully validated ItemCreate instance — Chapter 2's own type declaration on the route parameter did that work before this function was ever called. item.model_dump() unpacks the already-clean fields directly into a new Item row. There's no defensive checking left to write, because there's nothing left to defend against by this point.

What Happens When Validation Fails

Submitting a request missing name never reaches the function above at all — FastAPI intercepts it and returns a structured 422 response on its own:

{ "detail": [ { "loc": ["body", "name"], "msg": "Field required", "type": "missing" } ] }

Compare this to Food Tracker (React + Express)'s own error shape — a single, free-form string: { "error": "name is required" }. FastAPI's detail array pinpoints exactly which field failed and why, in a consistent, machine-readable structure — genuinely useful for mapping an error directly onto the specific form input that caused it, rather than displaying one generic message regardless of which field was actually wrong.

Mapping Errors to Form Fields

// static/app.js async function submitAddItem(formData) { const response = await fetch("/api/items", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(formData), }); if (response.status === 422) { const { detail } = await response.json(); showFieldErrors(detail); return; } const item = await response.json(); showHomeView(); } function showFieldErrors(detail) { clearFieldErrors(); for (const err of detail) { const fieldName = err.loc[err.loc.length - 1]; // e.g. "name" const el = document.querySelector(`[data-field-error="${fieldName}"]`); if (el) el.textContent = err.msg; } }

err.loc is itself an array — ["body", "name"] — describing exactly where in the request the problem was found; taking its last element gives the specific field name, letting showFieldErrors place each message right next to the input that actually caused it.

No duplicated validation logic on either side
An HTML5 required attribute on the name input gives instant, free client-side feedback for the obvious case — genuine UX, costing nothing to add. It is not, and doesn't need to be, a hand-written copy of the server's own validation logic the way Food Tracker (React + Express)'s own client-side check was: that course's if (!name.trim()) check duplicated real logic on both ends, while this course's client-side hint and Pydantic's own server-side schema never need to be kept in sync with each other at all, since neither one is a copy of the other's actual rules.
response_model does more than shape the docs
Declaring response_model=schemas.ItemResponse means FastAPI filters the outgoing response through that schema too — even if the Item SQLAlchemy object somehow carried an extra internal attribute never meant to be exposed, only the fields actually declared on ItemResponse would ever be serialized into the response. A genuine safety property, not just documentation sugar.

Where This Course Is Headed

Expiry alerts next — a date-filtered query for items expiring soon, and a dashboard endpoint.

Hands-On Exercises

Exercise 1

Explain why create_item's own function body contains no validation checks at all, tracing exactly what already happened to the request before that function was ever called.

📄 View solution
Exercise 2

Explain the concrete advantage of FastAPI's structured detail array over Food Tracker (React + Express)'s own single error string, specifically in terms of what showFieldErrors is able to do with it.

📄 View solution
Exercise 3

Explain why this chapter says there's "no duplicated validation logic on either side," contrasting that directly with Food Tracker (React + Express)'s own client-side name check.

📄 View solution

Chapter 5 Quick Reference

  • The route: POST / — six lines, no validation code, because Chapter 2's ItemCreate already did that work
  • 422 responses: a structured detail array naming exactly which field failed and why, unlike the Express sibling's single error string
  • showFieldErrors: maps each detail entry's loc directly onto the form field that caused it
  • No duplicated logic: HTML5 required is a free UX hint, not a hand-copied version of the server's own rules
  • response_model: also filters the outgoing response — a genuine safety property, not just documentation sugar
  • Next chapter: Expiry Alerts
Chapter 6 of 12

Expiry Alerts

Food Tracker (FastAPI)

Chapter 6 · Expiry Alerts

Every item added in Chapter 5 now has a real row, with a real (or None) expiry_date. This chapter turns that stored date into a list of what needs to be used soon.

The Alerts Query

# routers/items.py from datetime import date, timedelta @router.get("/alerts", response_model=list[schemas.ItemResponse]) def get_alerts(db: Session = Depends(get_db)): threshold = date.today() + timedelta(days=3) return ( db.query(models.Item) .filter(models.Item.status == "active") .filter(models.Item.expiry_date.isnot(None)) .filter(models.Item.expiry_date <= threshold) .order_by(models.Item.expiry_date) .all() )

status == "active" excludes anything already marked used (Chapter 8's own territory); expiry_date.isnot(None) excludes items with no expiry date at all — the same two conditions every sibling course's own alerts query applies.

A real advantage of a typed column, paid off here
Food Tracker (React + Express)'s own equivalent route had to warn explicitly that its date comparison only worked correctly because every date was consistently stored as YYYY-MM-DD text — SQLite has no dedicated date type, so that course's own expiry_date <= date('now', '+3 days') was comparing strings lexicographically, and would have silently broken if even one date were ever stored in a different format. Chapter 2's decision to model expiry_date as a real SQLAlchemy Date column, not a raw string, removes that entire class of bug here: models.Item.expiry_date <= threshold compares real date values, correctly, regardless of how any particular database driver happens to store them internally. This is a genuine, direct payoff of choosing an ORM with real typed columns back in Chapter 2, not something this route had to work around on its own.
Not every route benefits equally from async
Unlike Chapter 3's genuinely async httpx.AsyncClient call, get_alerts is a plain def, not async def — this uses the classic, synchronous SQLAlchemy session pattern (Session, not AsyncSession). A local SQLite query is fast enough that this rarely matters in practice at this app's own realistic scale, but it's worth naming honestly rather than implying every route in this course is async by default: Chapter 3's route benefits from async because it waits on a genuinely slow external network call; this one queries a local file, where the same non-blocking benefit doesn't apply nearly as much, and a fully async SQLAlchemy setup (AsyncSession, an async database driver) was a deliberate scope decision this course doesn't take on.

The Dashboard View

// static/app.js async function loadAlerts() { const response = await fetch("/api/items/alerts"); const alerts = await response.json(); const list = document.getElementById("alerts-list"); list.innerHTML = alerts.length === 0 ? "<li>Nothing expiring soon.</li>" : alerts.map((item) => `<li>${item.name} — expires ${item.expiry_date}</li>`).join(""); }
The threshold is computed in Python, not SQL
date.today() + timedelta(days=3) runs once in application code before the query executes, rather than being computed inside the SQL statement the way Food Tracker (React + Express)'s own date('now', '+3 days') was. Both approaches are valid; this one keeps the "how many days is soon" logic visible directly in Python, right next to the route that uses it, rather than buried inside a SQL string.

Where This Course Is Headed

Item history and live search next — a ?q= prefix-search endpoint and debounced frontend fetching.

Hands-On Exercises

Exercise 1

Explain the specific bug class this course's typed Date column avoids that Food Tracker (React + Express) had to name explicitly, tracing it back to a decision made in Chapter 2.

📄 View solution
Exercise 2

Explain why get_alerts is a plain def rather than async def, contrasting it with Chapter 3's own async lookup route.

📄 View solution
Exercise 3

Explain the difference between computing the expiry threshold in Python (date.today() + timedelta(days=3)) versus computing it inside the SQL query itself, as Food Tracker (React + Express) did.

📄 View solution

Chapter 6 Quick Reference

  • Route: GET /alerts — status='active', expiry_date not null, expiry_date <= today+3 days, ordered by expiry_date
  • Real advantage: a typed Date column avoids the TEXT-comparison gotcha Food Tracker (React + Express) had to warn about explicitly
  • Honest note: this route is a plain def, not async def — a local SQLite query doesn't benefit from async the way Chapter 3's external API call did
  • Threshold computed in Python: date.today() + timedelta(days=3), kept visible next to the route, not buried in a SQL string
  • Next chapter: Item History & Live Search-as-You-Type
Chapter 7 of 12

Item History & Live Search-as-You-Type

Food Tracker (FastAPI)

Chapter 7 · Item History & Live Search-as-You-Type

Every item ever added stays in the items table forever — Chapter 2's own design, active or used. This chapter surfaces that whole history, searchable in real time as the user types.

The Search Route

# routers/items.py @router.get("/search", response_model=list[schemas.ItemResponse]) def search_items(q: str = "", db: Session = Depends(get_db)): return ( db.query(models.Item) .filter(models.Item.name.ilike(f"%{q}%")) .order_by(models.Item.added_at.desc()) .limit(50) .all() )

Deliberately no status filter, exactly like every sibling course's own search route — this searches the entire history, active and used items both, since re-adding something bought before is exactly the case this endpoint exists for.

Zooming out: three of four courses share this advantage
.ilike() is SQLAlchemy's own built-in, explicitly-named case-insensitive LIKE — no separate lowercase field to maintain, the same real advantage Food Tracker (Django)'s icontains lookup and Food Tracker (React + Express)'s own raw SQLite LIKE both already claimed for themselves. All three of this quartet's SQL-backed courses get native, case-insensitive substring search essentially for free, each expressed in that framework's own idiom — ilike() here, icontains in Django, a plain LIKE in Express. Only Food Tracker (React + Firebase) needed a workaround at all, maintaining its own nameLower shadow field specifically because Firestore has no equivalent native capability. This isn't really a FastAPI-specific advantage — it's a relational-database advantage the three SQL courses in this quartet all happen to share, each one naming it in its own equivalent chapter.

A Debounce, Written as Plain Functions

The same underlying idea as Food Tracker (React + Express)'s own useDebouncedSearch hook, here with no hook at all — just setTimeout/clearTimeout and a module-level variable:

// static/app.js let searchTimeoutId = null; function onSearchInput(query) { clearTimeout(searchTimeoutId); searchTimeoutId = setTimeout(() => runSearch(query), 300); } async function runSearch(query) { const response = await fetch(`/api/items/search?q=${encodeURIComponent(query)}`); const results = await response.json(); renderSearchResults(results); } document.getElementById("search-input").addEventListener("input", (e) => { onSearchInput(e.target.value); });

clearTimeout(searchTimeoutId) at the start of onSearchInput cancels whatever timer the previous keystroke scheduled, before scheduling a new one — the same underlying discipline as Chapter 4's stopScanner() calls, applied here to a timer instead of a camera stream.

Lower stakes than Chapter 4's cleanup, worth noting honestly
Forgetting clearTimeout here would mean an extra, unnecessary search request firing occasionally — a wasted network call, nothing more. Chapter 4's own stopScanner() omission would leave a camera physically running in the background indefinitely. Both are the same underlying "clean up what the last run scheduled" pattern, but the actual cost of getting it wrong is meaningfully different — worth knowing which mistakes in a framework-free app are merely wasteful versus genuinely harmful.
LIMIT 50 is a scope decision, not real pagination
A history large enough to exceed 50 matches for a single search term would simply have its remaining results cut off, with no "load more" or page-through mechanism built here. A genuinely large history would need real pagination — outside this course's own realistic scope, but worth knowing as an honest limit rather than assuming the current query handles every possible case.

Where This Course Is Headed

Marking items used next — a PATCH endpoint transitioning status and clearing the expiry date without deleting the row.

Hands-On Exercises

Exercise 1

Explain why this chapter says the native case-insensitive substring search advantage isn't really FastAPI-specific, naming which three courses in the quartet share it and which one doesn't.

📄 View solution
Exercise 2

Explain why clearTimeout(searchTimeoutId) is described as the same underlying pattern as Chapter 4's stopScanner() calls, and why the two are still described as having meaningfully different stakes if forgotten.

📄 View solution
Exercise 3

Explain why the search route deliberately has no status filter, unlike Chapter 6's own alerts route.

📄 View solution

Chapter 7 Quick Reference

  • Route: GET /search?q=... — ilike('%'+q+'%'), no status filter, limited to 50 results
  • Quartet-wide finding: Django's icontains, Express's LIKE, and this course's ilike() all share the same native advantage — only the Firebase sibling needed a nameLower workaround
  • Debounce: plain setTimeout/clearTimeout, the same underlying pattern as Chapter 4's stopScanner(), but lower stakes if forgotten
  • Honest limit: LIMIT 50 is a scope decision, not real pagination
  • Next chapter: Marking Items Used
Chapter 8 of 12

Marking Items Used

Food Tracker (FastAPI)

Chapter 8 · Marking Items Used

Every earlier chapter built toward this exact moment: an item is finally used up, and the row created back in Chapter 5 needs to reflect that — without ever disappearing from the history Chapter 7 searches.

The Route

# routers/items.py from datetime import datetime @router.patch("/{item_id}/use", response_model=schemas.ItemResponse) def mark_used(item_id: int, db: Session = Depends(get_db)): item = ( db.query(models.Item) .filter(models.Item.id == item_id, models.Item.status == "active") .first() ) if item is None: raise HTTPException(status_code=404, detail="Item not found, or already used") item.status = "used" item.used_at = datetime.utcnow() item.expiry_date = None db.commit() db.refresh(item) return item

expiry_date = None pays off Chapter 2's own nullable design — the row stays in the table forever, but its live expiry date genuinely goes away, exactly what Chapter 6's own expiry_date.isnot(None) filter already expects. The combined item_id == item_id, status == "active" filter means marking an already-used item "used" again matches no row at all — a safe no-op, not a re-stamped timestamp.

A small, real advantage in the path parameter too
item_id: int isn't just documentation — declaring the path parameter's type means FastAPI validates and coerces it automatically, the same mechanism Chapter 5's own request-body validation used. A request to /api/items/abc/use is rejected with a 422 before mark_used's own body ever runs, since "abc" can't be parsed as an int — one more place this course's validation happens structurally, through a type annotation, rather than through a hand-written check.
This must be a PATCH, never a plain link
A state-changing action like this must never be reachable via a plain GET — a browser's own link-prefetching or a crawler following every link on a page could trigger it without the user ever intending to. PATCH requires an explicit fetch call from real JavaScript, never something a browser might do on its own while simply loading a page.

Wiring It Into the Frontend

// static/app.js async function markUsed(id) { await fetch(`/api/items/${id}/use`, { method: "PATCH" }); loadAlerts(); if (!document.getElementById("recipes-view").classList.contains("hidden")) { loadRecipeSuggestions(); } }
Why this course never needed anything like Food Tracker (React + Express)'s own Context
That course's own Chapter 10 built a shared ItemsContext specifically because several separate React components each needed to react to the same mutation, with no direct relationship between them. This app has no component tree at all — markUsed can simply call loadAlerts() and loadRecipeSuggestions() directly, by name, because there are only ever a handful of views and no framework-imposed boundary between them. This isn't a missing feature; it's the direct, honest payoff of Chapter 1's own "deliberately minimal frontend" choice — a coordination problem only really needs a coordination mechanism once the app is complex enough to have one, and this one deliberately isn't.

Where This Course Is Headed

Recipe lookup with TheMealDB next — a second external API integration, reusing Chapter 3's own lessons.

Hands-On Exercises

Exercise 1

Explain what the combined item_id == item_id, status == "active" filter actually prevents, and what would go wrong without the status == "active" part if a user managed to click "Mark Used" twice.

📄 View solution
Exercise 2

Explain what happens to a request to /api/items/abc/use, and why this counts as the same kind of validation Chapter 5 covered for request bodies, just applied to a path parameter instead.

📄 View solution
Exercise 3

Explain why this course never needed a coordination mechanism like Food Tracker (React + Express)'s own ItemsContext, tracing the reason back to a decision made in Chapter 1.

📄 View solution

Chapter 8 Quick Reference

  • Route: PATCH /{item_id}/use — sets status='used', used_at, clears expiry_date to None
  • Guard: filtering on status == "active" too makes a duplicate click a safe no-op
  • Path parameter validation: item_id: int is validated automatically, the same mechanism as Chapter 5's own body validation
  • Never a GET: a state-changing action must require an explicit fetch call, not something a browser could trigger on its own
  • No Context needed: markUsed calls loadAlerts()/loadRecipeSuggestions() directly — a direct payoff of this course's own deliberately minimal frontend
  • Next chapter: Recipe Lookup with TheMealDB
Chapter 9 of 12

Recipe Lookup with TheMealDB

Food Tracker (FastAPI)

Chapter 9 · Recipe Lookup with TheMealDB

Chapter 6's alerts query already knows what's expiring soon. This chapter takes that same list and asks a second free API, TheMealDB, what could actually be cooked with it — reusing every lesson Chapter 3 already taught about proxying an external API, plus one genuinely new gotcha this specific combination introduces.

A Recipe Cache, Same Pattern as Chapter 3

# models.py (appended) class RecipeCache(Base): __tablename__ = "recipe_cache" ingredient = Column(String, primary_key=True) meals = Column(String, nullable=False) # JSON array, stored as text cached_at = Column(DateTime, server_default=func.now())

Real Concurrency via asyncio.gather()

Multiple ingredients need looking up at once — the same fan-out shape every sibling course faces. FastAPI's own async model makes genuine concurrency the natural way to write this, not a special optimization bolted on afterward:

# routers/recipes.py import asyncio, json from database import SessionLocal async def lookup_ingredient(ingredient: str) -> list[dict]: key = ingredient.lower().strip().replace(" ", "_") db = SessionLocal() # a dedicated session for this one concurrent task try: cached = db.query(models.RecipeCache).filter_by(ingredient=key).first() if cached: return json.loads(cached.meals) async with httpx.AsyncClient() as client: response = await client.get( f"https://www.themealdb.com/api/json/v1/1/filter.php?i={key}", timeout=5.0, ) meals = response.json().get("meals") or [] db.merge(models.RecipeCache(ingredient=key, meals=json.dumps(meals))) db.commit() return meals finally: db.close() @router.get("/suggest") async def suggest_recipes(db: Session = Depends(get_db)): threshold = date.today() + timedelta(days=3) expiring = ( db.query(models.Item) .filter(models.Item.status == "active", models.Item.expiry_date.isnot(None)) .filter(models.Item.expiry_date <= threshold) .all() ) results = await asyncio.gather(*(lookup_ingredient(i.name) for i in expiring)) match_counts = {} for meals in results: for meal in meals: entry = match_counts.setdefault(meal["idMeal"], {**meal, "matchCount": 0}) entry["matchCount"] += 1 ranked = sorted(match_counts.values(), key=lambda m: m["matchCount"], reverse=True) return ranked[:10]
A real gotcha specific to this combination: one Session, many concurrent tasks
Every earlier chapter used the single request-scoped session from Depends(get_db) — perfectly fine when only one query runs at a time. asyncio.gather() runs several lookup_ingredient calls concurrently, and SQLAlchemy's classic Session is explicitly not designed to be shared across concurrently-running tasks — interleaved queries and commits against one shared session can leave its internal state genuinely inconsistent. The fix above is deliberate: lookup_ingredient opens and closes its own dedicated SessionLocal() rather than reusing the route's own db parameter, giving each concurrent task a session entirely its own. This is a real, specific consequence of combining FastAPI's async concurrency with SQLAlchemy's synchronous session model — not a gotcha any of this quartet's other three courses have to think about in quite the same way.
Two of four courses in this quartet share this advantage
Food Tracker (Django)'s own equivalent view named its own fan-out honestly as sequential — one TheMealDB call waiting for the previous one to finish, a real, admitted cost. asyncio.gather() here fires every ingredient's lookup concurrently instead, the exact same category of advantage Food Tracker (React + Express)'s own Promise.all already claimed for itself in that course's own Chapter 9 — two genuinely different languages, Python and JavaScript, each with a real async model, both reaching the same concurrent result for the same underlying reason: an async-first runtime turns out to matter for more than just how a single request feels to write.
The cache is per-ingredient, not per-suggestion
Caching keyed by ingredient means a cached "chicken" lookup gets reused the next time chicken appears in the alerts list, regardless of what else happened to be expiring alongside it that day — the same granular-caching principle as every sibling course's own recipe cache.

Where This Course Is Headed

Async patterns and background tasks next — a deeper look at async def, BackgroundTasks, and connection-pooling considerations, building directly on this chapter's own concurrency work.

Hands-On Exercises

Exercise 1

Explain why sharing the route's own Depends(get_db) session across every concurrent lookup_ingredient call would be unsafe, and what lookup_ingredient does instead to avoid the problem.

📄 View solution
Exercise 2

Explain why this course and Food Tracker (React + Express) both achieve real concurrent fan-out for the same underlying reason, despite being written in two completely different languages.

📄 View solution
Exercise 3

Explain why recipe_cache is keyed by ingredient rather than by the full combination of expiring items on any given day.

📄 View solution

Chapter 9 Quick Reference

  • Route: GET /suggest — asyncio.gather() fans out to TheMealDB per expiring ingredient, merges and sorts by match count
  • New table: recipe_cache, keyed by ingredient, storing the JSON meal list as text
  • Real gotcha: a shared synchronous SQLAlchemy Session is unsafe across concurrent asyncio tasks — each lookup_ingredient call opens and closes its own dedicated session
  • Real advantage: asyncio.gather() matches Food Tracker (React + Express)'s own Promise.all — two of four courses achieve genuine concurrency here, vs. Food Tracker (Django)'s own honestly-named sequential cost
  • Next chapter: Async Patterns & Background Tasks
Chapter 10 of 12

Async Patterns & Background Tasks

Food Tracker (FastAPI)

Chapter 10 · Async Patterns & Background Tasks

This course has already used async two different ways — a single external call in Chapter 3, a concurrent fan-out in Chapter 9. This chapter steps back to make the decision rule explicit, then adds one genuinely new tool: doing real work after a response has already been sent.

When async def Actually Helps

RouteWhat it waits onasync def helps?
Chapter 3 — lookup_barcodeA network call to Open Food FactsYes — genuine idle waiting time, real benefit
Chapter 6 — get_alertsA local SQLite queryBarely — the query finishes almost immediately
Chapter 9 — suggest_recipesSeveral network calls, run concurrentlyYes — the whole point of asyncio.gather()

The rule, stated plainly: async def earns its keep when a route genuinely waits on something slow and external — a network call, most often. A route that only touches a fast local resource gains little from being async, and Chapter 6 was written as a plain def specifically for that reason.

Doing Work After the Response Is Sent

Every mutation so far has made the client wait for absolutely everything to finish before responding. BackgroundTasks lets a route respond immediately and still do a little extra work afterward — here, logging every "marked used" event to its own table:

# models.py (appended) class UsageLog(Base): __tablename__ = "usage_log" id = Column(Integer, primary_key=True) item_id = Column(Integer, nullable=False) item_name = Column(String, nullable=False) logged_at = Column(DateTime, server_default=func.now())
# routers/items.py from fastapi import BackgroundTasks from database import SessionLocal def log_usage(item_id: int, item_name: str): db = SessionLocal() # the request's own session is already closed by now try: db.add(models.UsageLog(item_id=item_id, item_name=item_name)) db.commit() finally: db.close() @router.patch("/{item_id}/use", response_model=schemas.ItemResponse) def mark_used(item_id: int, background_tasks: BackgroundTasks, db: Session = Depends(get_db)): item = ( db.query(models.Item) .filter(models.Item.id == item_id, models.Item.status == "active") .first() ) if item is None: raise HTTPException(status_code=404, detail="Item not found, or already used") item.status = "used" item.used_at = datetime.utcnow() item.expiry_date = None db.commit() db.refresh(item) background_tasks.add_task(log_usage, item.id, item.name) return item

background_tasks.add_task(...) schedules log_usage to run after the response has already been sent — the client gets its confirmation immediately, without waiting for the audit-log write to complete.

The same dedicated-session discipline as Chapter 9, for a different reason
log_usage opens its own SessionLocal() rather than reusing mark_used's own db parameter — the same pattern Chapter 9's lookup_ingredient used, but for a genuinely different underlying reason. Chapter 9's issue was concurrency: several tasks running at the same time couldn't safely share one session. Here, the issue is lifecycle: by the time log_usage actually runs, the request is already fully complete, and get_db's own finally: db.close() has already closed mark_used's session. A background task reusing that closed session would fail outright — it isn't a race condition to avoid, it's a session that's simply already gone.
BackgroundTasks is not a durable job queue
log_usage runs in the same process, after the response — genuinely useful for quick, best-effort extra work, but with a real limit worth stating plainly: if the server process crashes or restarts in the narrow window between sending the response and the task actually executing, that task is simply lost, with no retry and no record that it was ever supposed to run. A real job queue (Celery, RQ, or similar) persists tasks somewhere durable and can retry them after a crash — BackgroundTasks offers none of that. It's the right tool for a nice-to-have audit log; it would be the wrong tool for something that genuinely must happen, like sending a payment confirmation.

A Note on Connection Pooling

# database.py engine = create_engine( "sqlite:///./foodtracker.db", connect_args={"check_same_thread": False}, )

check_same_thread=False matters specifically because of this chapter's own material: FastAPI runs synchronous functions like log_usage in a background threadpool, a different thread than the one that originally handled the request. SQLite's default behavior disallows using a connection from a different thread than the one that created it, purely as a safety guard — this flag deliberately relaxes that, since SessionLocal()'s own connection handling (not manual thread-sharing) is what's actually managing safe access here.

SQLite doesn't really have a "pool" the way a networked database does
A real connection pool exists to reuse a limited number of expensive network connections to a remote database server — genuinely valuable for something like PostgreSQL. SQLite is a local file, not a network service; there's no remote connection to establish or reuse in the same sense. A high-traffic, genuinely concurrent production deployment would be a real reason to move to PostgreSQL and a proper connection pool — an honest, deliberately out-of-scope upgrade path for this app's own realistic single-household scale, not something this course builds.

Where This Course Is Headed

Deployment next — environment config, running with Uvicorn/Gunicorn, and a real production checklist.

Hands-On Exercises

Exercise 1

Using this chapter's own table, explain the general rule for when async def genuinely helps a route, and why Chapter 6's get_alerts was written as a plain def despite this course being "async-native."

📄 View solution
Exercise 2

Explain why log_usage needs its own SessionLocal() rather than reusing mark_used's own db parameter, and how this reason genuinely differs from why lookup_ingredient needed its own session in Chapter 9.

📄 View solution
Exercise 3

Explain what "BackgroundTasks is not a durable job queue" actually means in practice, describing a concrete scenario where a scheduled background task could be silently lost.

📄 View solution

Chapter 10 Quick Reference

  • The rule: async def helps when a route waits on something slow and external — not every route benefits equally
  • BackgroundTasks: background_tasks.add_task(...) runs work after the response is already sent
  • log_usage's own session: a fresh SessionLocal(), because the request's own session is already closed by the time this runs — a lifecycle issue, distinct from Chapter 9's own concurrency issue
  • Real limit: BackgroundTasks is in-process and best-effort — a crash before it runs loses it silently, with no retry
  • check_same_thread=False: needed because sync background tasks run in a different thread than the request that scheduled them
  • SQLite has no real connection pool — that concept matters for a networked database like PostgreSQL, not a local file
  • Next chapter: Deployment
Chapter 11 of 12

Deployment

Food Tracker (FastAPI)

Chapter 11 · Deployment

Every earlier chapter ran with uvicorn main:app --reload — a genuinely fine development server, and a genuinely wrong choice for real production traffic.

Running With Multiple Workers

pip install gunicorn gunicorn main:app -k uvicorn.workers.UvicornWorker -w 4 --bind 0.0.0.0:8000

Gunicorn manages several separate Uvicorn worker processes — a standard production pattern for FastAPI, giving real request-handling capacity beyond what a single process can provide, and automatically restarting a worker that crashes.

This course never needed CORS at all
Django, Express, and Firebase's own frontends each run as a genuinely separate process or origin from their own backend during development, requiring CORS configuration to let them talk to each other. This course's own Chapter 1 mounted its static frontend directly on the same FastAPI app via StaticFiles — the frontend and API have always shared one origin, in development and in production alike. There's no CORS middleware anywhere in this course, not because it was overlooked, but because Chapter 1's own architecture never created the cross-origin situation that would require it.

A Full-Circle Caveat on Chapter 1's Own Favorite Feature

Chapter 1 named automatic interactive docs at /docs as a genuine advantage none of this quartet's other courses get for free. Before a real deployment, that same feature deserves an honest second look:

# main.py import os app = FastAPI( title="Food Tracker", docs_url="/docs" if os.getenv("ENVIRONMENT") != "production" else None, redoc_url=None, )
The same feature, praised in Chapter 1, caveated here
A genuinely useful development tool and a genuinely public API explorer are not automatically the same thing to want live in production. /docs lets anyone browsing it try real API calls directly against the running app — fine, even valuable, during development; worth a deliberate decision, not an accidental default, before a real public launch. Disabling it conditionally, as above, keeps Chapter 1's own advantage intact during development while making its production exposure an explicit choice rather than something that just happens by default.

SQLite in Production: A Familiar Concern, and a New One

Food Tracker (React + Express)'s own deployment chapter named the risk of an ephemeral filesystem silently wiping the SQLite file on redeploy — the same risk applies here, for the same reason: confirm the deployment target keeps foodtracker.db on a volume that survives a restart.

This course introduces one genuinely new SQLite consideration the single-process Express deployment never had to face:

Multiple worker processes writing to one SQLite file
-w 4 above means four entirely separate operating-system processes, each with its own database connection to the same foodtracker.db file. SQLite handles multiple processes reasonably well for read-heavy workloads, but under genuine concurrent write contention across several processes at once, it can return real "database is locked" errors — a cost this course's own move to multiple worker processes trades away some of SQLite's single-process simplicity for. Chapter 10 already named that SQLite has no real connection pool the way a networked database does; this is the concrete, production-shaped consequence of that same limitation. At this app's own realistic single-household scale, simultaneous writes are genuinely rare, so a small worker count stays a reasonable choice — a busier real deployment would be a real, honest reason to move to PostgreSQL instead, exactly the same upgrade path Chapter 10 already named.

TLS Termination

Chapter 4's own camera access requires HTTPS in production. Gunicorn itself doesn't handle TLS certificates — a reverse proxy (nginx, the same pattern already covered in this site's own Nginx In Depth course) sits in front of it, terminating HTTPS and forwarding plain HTTP internally, the same general shape both the Django and Express siblings' own deployment chapters already used.

A Production Checklist

  • Run with gunicorn + Uvicorn workers, never --reload.
  • Decide deliberately whether /docs should be public, and disable it if not.
  • Confirm the SQLite file lives on a volume that survives a redeploy.
  • Keep worker count modest, or plan a PostgreSQL migration, if genuine concurrent writes are expected.
  • Put a reverse proxy in front for TLS termination.
  • Load real configuration (database path, environment name) from environment variables, not hardcoded values.

Where This Course Is Headed

One chapter left: a capstone tying every chapter into one complete, working app.

Hands-On Exercises

Exercise 1

Explain why this course never needed CORS configuration anywhere, tracing the reason back to a decision made in Chapter 1.

📄 View solution
Exercise 2

Explain why Chapter 1 named /docs as a genuine advantage, and why this chapter still recommends deliberately deciding whether to expose it in production rather than leaving the default as-is.

📄 View solution
Exercise 3

Explain why running with multiple gunicorn workers introduces a SQLite concern that Food Tracker (React + Express)'s own single-process deployment never had to face, and how this connects back to a point Chapter 10 already made.

📄 View solution

Chapter 11 Quick Reference

  • Run with: gunicorn -k uvicorn.workers.UvicornWorker -w 4, never --reload in production
  • No CORS anywhere: StaticFiles has served the frontend same-origin since Chapter 1 — a real simplicity payoff none of the three sibling courses share
  • Full-circle caveat: /docs, Chapter 1's own praised feature, should be a deliberate production decision, not an accidental default
  • Familiar SQLite risk: confirm a persistent volume, same as the Express sibling's own warning
  • New SQLite risk: multiple worker processes writing to one file can hit real lock contention — a direct consequence of Chapter 10's own "no real connection pool" point
  • TLS: a reverse proxy in front, same pattern as the Django and Express siblings
  • Next chapter: Capstone
Chapter 12 of 12

Capstone: A Complete, Working Food Tracker

Food Tracker (FastAPI)

Chapter 12 · Capstone: A Complete, Working Food Tracker

Priya keeps her own household pantry tracked with the app this course just spent eleven chapters building — one FastAPI process, one SQLite file, one deliberately plain frontend, all deployed and running for real. What follows is one ordinary session with it.

Step 1 — Checking /docs First

Before touching the frontend at all, Priya's own developer opens /docs to confirm the deployment is healthy — Chapter 1's own praised automatic API explorer, still available in this deployment since it was left enabled deliberately, per Chapter 11's own decision.

Step 2 — Scanning a Carton of Eggs

Priya scans the barcode. Chapter 4's plain-function startScanner decodes it client-side, calling Chapter 3's genuinely async lookup route — httpx.AsyncClient checking BarcodeCache first, falling back to a live Open Food Facts call, with the event loop free to serve other requests the whole time it waits.

Step 3 — Adding It

The add-item view pre-fills from that lookup. Priya sets the expiry date and submits; Chapter 5's ItemCreate schema validates the request before create_item's own body ever runs — no hand-written checks anywhere in that route, because there's nothing left to check by the time it executes.

Step 4 — An Alert, a Few Days Later

Chapter 6's alerts route surfaces the eggs once they're within three days of expiring — a real typed Date column comparison, correct regardless of formatting, and a plain def since a local SQLite query never needed async in the first place.

Step 5 — Searching for Something Bought Before

Priya searches "egg." Chapter 7's ilike() finds it instantly — no shadow field, the same real advantage Django and Express both claim in their own courses.

Step 6 — Marking Them Used, and a Quiet Log Entry

The eggs get used up. Chapter 8's PATCH /{item_id}/use clears expiry_date and guards against a duplicate click, then Chapter 10's BackgroundTasks schedules a UsageLog entry — written after the response is already back in Priya's browser, using its own dedicated session since the request's own is already closed by then.

Step 7 — Everything Updates, Directly

The same markUsed function calls loadAlerts() and loadRecipeSuggestions() directly, by name — Chapter 8's own honest point still holds: this app never needed anything like the Express sibling's own Context, because there's no component tree creating that coordination problem in the first place.

Step 8 — A Recipe Suggestion

With chicken and spinach both nearing expiry, Chapter 9's asyncio.gather() fans out to TheMealDB concurrently — each lookup_ingredient call opening its own dedicated session, genuinely safe under concurrency, unlike the bug that pattern was specifically designed to avoid — ranked by how many expiring ingredients each recipe actually uses.

Step 9 — All of This, Actually Deployed

Priya's whole session runs against a real deployment: gunicorn managing several Uvicorn workers, no CORS configuration anywhere since the frontend has shared FastAPI's own origin since Chapter 1, worker count kept modest specifically to avoid the SQLite lock contention Chapter 11 named honestly, and nginx terminating HTTPS in front of it all.

Chapter Attribution

StepChapter(s) applied
1 — /docsChapter 1 (automatic docs), Chapter 11 (deliberately kept enabled)
2 — ScanningChapter 4 (camera scanning), Chapter 3 (async lookup + BarcodeCache)
3 — Adding the itemChapter 5 (ItemCreate validation), Chapter 2 (schema, INSERT)
4 — Expiry alertChapter 6 (typed Date comparison, plain def)
5 — SearchChapter 7 (ilike(), native case-insensitive substring match)
6 — Marking used + loggingChapter 8 (PATCH, active-only guard), Chapter 10 (BackgroundTasks, dedicated session)
7 — Direct updatesChapter 8 (no Context needed)
8 — Recipe suggestionChapter 9 (asyncio.gather(), per-task session, relevance sort)
9 — Real deploymentChapter 11 (gunicorn, no CORS, worker count, TLS)
What this whole course was really about
Chapter 1 opened with three concrete claims about FastAPI: async-native, Pydantic validation built in, automatic docs. Every step above tested one of those claims against a real scenario, and none of them were treated as unconditional wins. Async genuinely paid off in Steps 2 and 8 (a real network wait, a real concurrent fan-out) and honestly didn't in Step 4, exactly as Chapter 6 said it wouldn't. Pydantic validation eliminated an entire category of hand-written checks in Steps 3 and 6. And /docs, praised in Chapter 1, got an honest second look in Chapter 11 before Step 1 could responsibly happen at all. This course's own throughline was never "FastAPI does everything automatically" — it was "here's exactly where FastAPI's own design choices pay off, and here's exactly where they don't," the same calibrated honesty every course in this quartet has tried to hold itself to.
Honest scope note
This capstone deliberately stops short of several things: the weekly meal planner named as future work all the way back in Chapter 1 was never built; there's no offline/PWA support; no multi-user ownership model exists anywhere in this course — the same honest gap named in both the Django and Express siblings' own capstones, unlike the Firebase sibling's own real per-user authentication; Chapter 9's own limit on matching generic branded product names against TheMealDB's fixed vocabulary was never solved, only named; Chapter 10's own honest point that BackgroundTasks is not a durable job queue was never resolved with a real job queue; and no automated test suite or CI pipeline was covered anywhere in this course.
The Food Tracker Quartet is now complete
This closes out all four Food Tracker courses — the same app, deliberately rebuilt four times in four genuinely different architectures: Django's batteries-included MVT model, React + Express's full-JavaScript stack, React + Firebase's backend-as-a-service model, and this course's own async-native FastAPI backend with a deliberately plain vanilla-JS frontend. Fifty chapters in total, each course naming its own real advantages and honest limits rather than declaring any single architecture simply "the best" one.

Hands-On Exercises

Exercise 1

Trace Step 6 in detail: explain what happens to the response Priya's browser receives versus what happens afterward in the background, and why the background portion needs its own database session.

📄 View solution
Exercise 2

Pick any two steps from Priya's session and explain how each one depends on at least two earlier chapters working together, not just one chapter in isolation.

📄 View solution
Exercise 3

Explain the finding-box's own claim that none of Chapter 1's three opening claims (async-native, Pydantic validation, automatic docs) were treated as unconditional wins across this course. Give one concrete example for each of the three claims.

📄 View solution

Chapter 12 Quick Reference — Course Complete

  • 9 steps, 11 prior chapters — one continuous, realistic session with the finished, deployed app
  • This course's own throughline, closed out: exactly where FastAPI's own design pays off, and exactly where it doesn't — never an unconditional win
  • Honest scope note: no meal planner, no offline/PWA, no multi-user ownership model (same gap as Django and Express), TheMealDB matching remains best-effort, BackgroundTasks isn't durable, no automated tests/CI
  • Food Tracker (FastAPI) is now complete — 12/12 chapters
  • The entire Food Tracker Quartet is now complete — 4 courses, 50 chapters, one app, four architectures