Add a database: SQLite first, then Postgres
Make orders survive restarts: add SQLAlchemy models, run on SQLite, then switch the same code to Postgres with one environment variable. You also learn about migrations and why hosted apps use a managed database.
Right now your API forgets every order when the server restarts, because orders live in a Python dictionary. In this lesson you give it a memory: a database. You will start with SQLite, which needs no installation, and then switch to Postgres, the database most real apps use, by changing one setting.
You need: the API from the last lesson running on your laptop, and a free AI assistant. For the Postgres part you need either Docker Desktop or a free hosted Postgres account (both options are shown). If you skip Postgres for now, you can still finish the lesson and come back before the deploy lesson.
Databases in two minutes
A relational database stores data in tables, like spreadsheets with strict columns. Each row is one record. Tables link to each other through ids. You talk to it in SQL, for example SELECT * FROM orders WHERE status = 'received'.
Writing SQL by hand inside Python is tedious, so we use an ORM (object-relational mapper). SQLAlchemy lets you describe a table as a Python class and work with rows as normal objects. It writes the SQL for you, and it passes values to the database safely. That last part matters: building SQL by gluing strings together lets attackers inject their own SQL (SQL injection). With an ORM you avoid that by default.
SQLite and Postgres
| Database | What it is | Good for |
|---|---|---|
| SQLite | One file on disk (brew.db). No server to install. Part of Python | Learning, tests, small tools, prototypes |
| Postgres | A database server that many programs can use at once | Production apps, hosting, many users |
The plan is to write the code once with SQLAlchemy so that both databases work. Only the connection URL changes: sqlite:///./brew.db on your laptop, a postgresql://... address in production.
What you will do
- 1Design two tables: orders and order lines.
- 2Prompt the AI to add SQLAlchemy to the API.
- 3Review the code, especially sessions and commits.
- 4Run it and check that orders survive a restart.
- 5Look inside the database.
- 6Switch to Postgres with one environment variable.
Step 1: Two tables
An order has many lines (2 espressos, 1 croissant). You cannot put a variable-length list in one column, so you use two tables: one row per order, and one row per line that points back to its order. This is a one-to-many relationship, and the pointer is a foreign key.
The tables
| Table | Columns |
|---|---|
| orders | id (primary key), customer_name, phone, total, status, pickup_time, created_at |
| order_items | id (primary key), order_id (foreign key to orders.id), item_id, name, price, qty |
The order line stores name and price itself, copied from the menu when the order was placed. As you decided in the design lesson, a later price change must not rewrite old orders. The menu itself stays in app/menu.py for now.
Step 2: Prompt the AI
I have a working FastAPI app (code below) that keeps orders in a Python dict. Replace the dict with a database using SQLAlchemy 2.0 (the Mapped and mapped_column style) so that the same code works with SQLite and Postgres.
Requirements:
- New file app/db.py: engine from the DATABASE_URL environment variable, default sqlite:///./brew.db. If the URL starts with postgres:// or postgresql://, change it to postgresql+psycopg:// so SQLAlchemy uses the psycopg driver. Add check_same_thread=False for SQLite only. A get_db() dependency that gives each request its own session and always closes it.
- New file app/models.py: tables orders and order_items with a one-to-many relationship, as described below.
- Datetimes: store UTC, and make sure datetimes read from SQLite come back timezone-aware.
- Update app/main.py: use Depends(get_db), create tables at startup with a lifespan function, commit after changes. Keep all endpoint behaviour, status codes and rules exactly the same. Keep the Pydantic schemas unchanged.
- No Alembic yet. No raw SQL strings.
Tables:
[paste the table list from this lesson]
Current code:
[paste app/main.py and app/schemas.py]
Return only the new or changed files.What to expect: Check that the AI kept your endpoints unchanged, did not invent new columns, and did not build SQL with f-strings.
Step 3: Review the database code
Database code review checklist
- Each request gets its own session, and the session is closed at the end (look for try and finally).
- Changes are saved with db.commit(). Without it, nothing is stored.
- No SQL is built by joining strings with user input.
- The total is still computed on the server from the menu, then stored.
- The tables are created at startup, but existing data is never deleted.
- The database URL comes from the environment, and no password is in the code.
- Datetimes are stored and returned as UTC with a timezone.
Here is the reference version. Start with the connection. db.py creates the engine (the connection manager), a session factory (a session is one conversation with the database, a unit of work) and a get_db function. FastAPI's Depends(get_db) calls it for each request, so each request gets a fresh session and it is always closed, even if an error happens.
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./brew.db")
# Hosts often give a URL that starts with postgres:// or postgresql://.
# SQLAlchemy needs to be told which Python driver to use (psycopg).
if DATABASE_URL.startswith("postgres://"):
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql+psycopg://", 1)
elif DATABASE_URL.startswith("postgresql://"):
DATABASE_URL = DATABASE_URL.replace("postgresql://", "postgresql+psycopg://", 1)
# SQLite refuses to share one connection between threads unless told it is fine.
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
engine = create_engine(DATABASE_URL, connect_args=connect_args)
SessionLocal = sessionmaker(bind=engine, autoflush=False)
class Base(DeclarativeBase):
pass
def get_db():
"""Give each request its own session and always close it."""
db = SessionLocal()
try:
yield db
finally:
db.close()One place that knows how to connect. Everything else imports from here.
Now the tables as Python classes. Each class is a table. Each Mapped[...] line is a column. relationship connects Order.items to its lines, and cascade="all, delete-orphan" means lines are saved with their order and deleted with it.
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import TypeDecorator
from app.db import Base
class UTCDateTime(TypeDecorator):
"""SQLite drops the timezone. Put it back so every datetime is UTC."""
impl = DateTime(timezone=True)
cache_ok = True
def process_result_value(self, value, dialect):
if value is not None and value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value
class Order(Base):
__tablename__ = "orders"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
customer_name: Mapped[str] = mapped_column(String(60))
phone: Mapped[str] = mapped_column(String(10))
total: Mapped[int] = mapped_column(Integer)
status: Mapped[str] = mapped_column(String(20), default="received")
pickup_time: Mapped[datetime] = mapped_column(UTCDateTime)
created_at: Mapped[datetime] = mapped_column(UTCDateTime)
items: Mapped[list["OrderItem"]] = relationship(
back_populates="order",
cascade="all, delete-orphan",
order_by="OrderItem.id",
)
class OrderItem(Base):
__tablename__ = "order_items"
id: Mapped[int] = mapped_column(primary_key=True)
order_id: Mapped[str] = mapped_column(ForeignKey("orders.id"), index=True)
item_id: Mapped[str] = mapped_column(String(40))
name: Mapped[str] = mapped_column(String(80))
price: Mapped[int] = mapped_column(Integer) # price per item at order time
qty: Mapped[int] = mapped_column(Integer)
order: Mapped["Order"] = relationship(back_populates="items")The UTCDateTime class fixes a real SQLite quirk: SQLite stores datetimes without a timezone. Without it the API would return a time with no Z, and the browser would read it as local time.
Finally the endpoints. Compare with the in-memory version: ORDERS[...] = ... became db.add, db.commit and db.get. The Pydantic response models are unchanged. Because OrderOut was built with from_attributes=True, FastAPI can turn a database object straight into the JSON response.
import os
import secrets
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from app.db import Base, engine, get_db
from app.menu import MENU, MENU_BY_ID
from app.models import Order, OrderItem
from app.schemas import MenuItem, OrderIn, OrderOut, StatusUpdate
@asynccontextmanager
async def lifespan(app: FastAPI):
# Create the tables if they do not exist yet. Runs once at startup.
Base.metadata.create_all(engine)
yield
app = FastAPI(title="Brew & Bean API", lifespan=lifespan)
# Which websites may call this API from a browser. Comma-separated list.
origins = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=[origin.strip() for origin in origins],
allow_methods=["GET", "POST", "PATCH"],
allow_headers=["Content-Type", "X-Admin-Key"],
)
# Minutes from "now" for each pickup choice.
PICKUP_MINUTES = {"asap": 10, "in_15": 15, "in_30": 30, "in_60": 60}
# Which status may follow which. A finished order cannot change again.
NEXT_STATUS = {
"received": {"preparing", "cancelled"},
"preparing": {"ready", "cancelled"},
"ready": {"picked_up"},
"picked_up": set(),
"cancelled": set(),
}
def require_admin(x_admin_key: str | None) -> None:
expected = os.getenv("ADMIN_KEY")
if not expected:
raise HTTPException(503, "ADMIN_KEY is not set on the server")
if not x_admin_key or not secrets.compare_digest(x_admin_key, expected):
raise HTTPException(401, "Missing or wrong admin key")
@app.get("/menu", response_model=list[MenuItem])
def get_menu():
return MENU
@app.post("/orders", response_model=OrderOut, status_code=201)
def create_order(order: OrderIn, db: Session = Depends(get_db)):
rows = []
total = 0
for line in order.items:
item = MENU_BY_ID.get(line.item_id)
if item is None:
raise HTTPException(422, f"Unknown item: {line.item_id}")
total += item.price * line.qty
rows.append(
OrderItem(item_id=item.id, name=item.name, price=item.price, qty=line.qty)
)
now = datetime.now(timezone.utc)
new_order = Order(
id=secrets.token_urlsafe(9),
customer_name=order.customer_name,
phone=order.phone,
total=total,
status="received",
pickup_time=now + timedelta(minutes=PICKUP_MINUTES[order.pickup]),
created_at=now,
items=rows,
)
db.add(new_order)
db.commit()
db.refresh(new_order)
return new_order
@app.get("/orders/{order_id}", response_model=OrderOut)
def get_order(order_id: str, db: Session = Depends(get_db)):
order = db.get(Order, order_id)
if order is None:
raise HTTPException(404, "Order not found")
return order
@app.patch("/orders/{order_id}/status", response_model=OrderOut)
def update_status(
order_id: str,
update: StatusUpdate,
x_admin_key: str | None = Header(default=None),
db: Session = Depends(get_db),
):
require_admin(x_admin_key)
order = db.get(Order, order_id)
if order is None:
raise HTTPException(404, "Order not found")
if update.status not in NEXT_STATUS[order.status]:
raise HTTPException(409, f"Cannot move from {order.status} to {update.status}")
order.status = update.status
db.commit()
db.refresh(order)
return orderChanged parts: the imports, the lifespan function that creates the tables, db: Session = Depends(get_db) in three endpoints, and the commit calls.
Step 4: Run it and restart it
# set the key again if this is a new terminal
export ADMIN_KEY=dev-admin-key # PowerShell: $env:ADMIN_KEY = "dev-admin-key"
uvicorn app.main:app --reloadOn first start, SQLAlchemy creates brew.db in the project folder, with both tables.
Place an order in http://localhost:8000/docs as before. Now stop the server with Ctrl+C, start it again, and GET /orders/{id} with the same id. The order is still there. That is persistence.
Keep data files out of Git
Never commit brew.db. It can contain real customers' names and phone numbers. The .gitignore you made earlier already lists *.db. Check it with git status before your first commit.
Step 5: Look inside the database
Seeing the rows removes the mystery. The simplest way that works everywhere is a few lines of Python using the built-in sqlite3 module:
python -c "import sqlite3; db = sqlite3.connect('brew.db'); print(db.execute('select id, customer_name, total, status from orders').fetchall()); print(db.execute('select order_id, item_id, price, qty from order_items').fetchall())"Prints the rows of both tables as lists.
[('VB24Zt9CIaFb', 'Asha', 390, 'received')]
[('VB24Zt9CIaFb', 'espresso', 120, 2), ('VB24Zt9CIaFb', 'croissant', 150, 1)]A visual tool
A free desktop app called DB Browser for SQLite lets you click through tables and run SQL. It is handy while learning. Close it before running the API if the API reports that the database is locked.
Step 6: Switch to Postgres
Because you used SQLAlchemy, switching is a setting. You have two ways to get a Postgres database. Option A runs one on your laptop with Docker. Option B uses a free hosted database such as Neon, which has a free plan at the time of writing (2026): create a project and copy its connection string. Option B also prepares you for deployment.
docker run --name brew-pg -e POSTGRES_PASSWORD=devpass -e POSTGRES_DB=brew -p 5432:5432 -d postgres:16Starts a Postgres server in a container with a database named brew. The password devpass is fine for a throwaway local database and must never be used for a real one. If port 5432 is taken, use -p 5433:5432 and change the port in the URL below.
# macOS, Linux, Git Bash
export DATABASE_URL=postgresql://postgres:devpass@localhost:5432/brew
# Windows PowerShell
$env:DATABASE_URL = "postgresql://postgres:devpass@localhost:5432/brew"
uvicorn app.main:app --reloadSame code, different database. db.py rewrites the URL to postgresql+psycopg:// for you. For a hosted database, paste the string the provider gives you instead (it usually ends with ?sslmode=require).
Place an order again. It goes to Postgres now. Prove it by asking Postgres directly:
docker exec brew-pg psql -U postgres -d brew -c "select id, customer_name, total, status from orders"Runs the psql command inside the container.
id | customer_name | total | status
--------------+---------------+-------+----------
sATlsccMUPR4 | Asha | 390 | received
(1 row)Remove DATABASE_URL (or open a new terminal) to go back to SQLite. This is also how production will work: the host sets DATABASE_URL, and your code does not change.
What changes
| Difference | SQLite | Postgres |
|---|---|---|
| Where it runs | In your program, as a file | A separate server |
| Timezones | Dropped when stored, which is why UTCDateTime exists | Kept |
| Many users at once | Limited | Built for it |
| Changing a table later | Limited ALTER support | Full |
| Hosting | Your disk may be wiped when a free host restarts | A managed database keeps data safe |
Why SQLite is not for the live site
On many free hosts the server's disk is temporary, so a SQLite file is lost on every restart or redeploy. This is the main reason the deployed API uses Postgres. SQLite stays for your laptop and for tests.
Changing tables later: migrations
create_all only creates tables that do not exist yet. If you add a column to Order later, an existing brew.db will not get it, and you will see errors like no such column. For a throwaway local database, delete brew.db and start again. For a real database with real orders you need migrations: small versioned scripts that change the tables step by step. The standard tool for SQLAlchemy is Alembic. It is beyond this project, and the AI can set it up when you need it. Know that it exists, and know why.
Common problems
Troubleshooting
| Problem | Cause and fix |
|---|---|
| sqlite3.OperationalError: no such table: orders | The tables were not created. Check that the lifespan function exists and that you started the server with uvicorn, not a script that skips it. |
| no such column | You changed a model after the database was created. Delete brew.db (local only) or write a migration. |
| ProgrammingError: SQLite objects created in a thread can only be used in that same thread | check_same_thread=False is missing for SQLite in db.py. |
| Can't load plugin: sqlalchemy.dialects:postgres | The URL starts with postgres://. Use the rewrite in db.py, or start the URL with postgresql://. |
| ModuleNotFoundError: No module named 'psycopg' | Run pip install -r requirements.txt in the active environment. |
| connection refused on port 5432 | The Postgres container is not running. Check with docker ps. |
| password authentication failed | The password in DATABASE_URL does not match the one used to start the database. |
| Times show an hour-wrong or no Z | A datetime column lost its timezone. Use UTCDateTime from models.py. |
I am using FastAPI with SQLAlchemy 2.0 and [SQLite | Postgres]. I get this error when I [what you did]. Explain what it means in simple words, which of my files is probably wrong, and the smallest fix. Here is the full error and the relevant code.
Error:
[paste the full error]
Code:
[paste models.py or db.py]What to expect: A good answer points to a specific line in your files and explains the cause before the fix.
Try it: Add a column and see what happens
Add an optional note column (a short text such as "no sugar") to the Order model, and accept an optional note in the order request. Start the server with your old brew.db. What error do you get when you place an order, and what are two ways to fix it?
Try it: Stretch: put the menu in the database
Create a MenuItem table, fill it from app/menu.py when the app starts (only if the table is empty), and make GET /menu and the order code read from it. Which parts of the code have to change?
Key takeaways
- A database keeps data when the server restarts. SQLite is a file for learning and tests. Postgres is a server for production.
- SQLAlchemy lets one codebase work with both. Only DATABASE_URL changes.
- Give each request its own session, commit your changes and close the session.
- Store a copy of the price in every order line so history stays correct.
- create_all does not change existing tables. Real projects use migrations (Alembic).
- Never commit database files or passwords. Keep URLs in environment variables.
Check yourself
Finishing the quiz marks the lesson as done.
1. Why does each request get its own database session through get_db?
2. You add a new column to the Order model and restart. Your existing brew.db gives "no such column". What happened?
3. Why does the deployed API use Postgres and not a SQLite file?
4. What is the safest way to put a customer's input into a database query?
5. Why is the item's price copied into the order_items table?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.