Design it on paper, model it in Python, and NEVER touch the schema by hand.
So Phase 0 got my little FastAPI app running. One route, one JSON response, a lot of pride. But here's the thing nobody warns you about: that app had the memory of a goldfish. Restart it and… nothing. No users, no data, no clue who anyone is.
Phase 1 fixes that. This is where the app gets an actual database — a place to remember things. And honestly? This is where "I'm following a tutorial" started turning into "oh, I kind of get how backends work now." I went in knowing zero SQL. Came out having designed tables, built models, run a real migration, and read/written actual data. Let me dump what I learned [and the parts that tripped me up, because there were a few].
The Structure is as follows. Lets call it PHASE 1 — The Data Layer:
- Get the right tools installed in the right place (SQLAlchemy + Alembic)
- Design the tables on paper BEFORE writing any code
- Turn that design into Python models
- Wire up the database connection
- Set up Alembic and run my first migration
- Actually write and read a row [the payoff!]
First, the two new toys
Two libraries do the heavy lifting here:
-
SQLAlchemy — the ORM. Fancy word, simple job: it lets me talk to the database in Python instead of writing raw SQL. I write
session.add(user), it writes theINSERTfor me. - Alembic — the migration tool. Think of it as git, but for the shape of your database. Every time I change the table structure, it saves that change as a numbered file I can replay or roll back.
source venv/Scripts/activate # Windows/Git Bash = Scripts/, not bin/ [learned this the hard way in Phase 0]
pip install sqlalchemy alembic
pip freeze > requirements.txt
Gotcha #1: I first installed these in a random throwaway folder, then wondered why my real project couldn't see them. Turns out a virtual environment is per-folder — packages installed in one venv are invisible to another. Reinstalled in the actual project. Lesson: check for
(venv)in your prompt and make sure it's the right one before you install anything.
Step 1: Draw it on paper (yes, really)
Before touching code, I sketched two boxes:
USERS EXPENSES
id (primary key) id (primary key)
name amount (money)
email (unique) description
created_at / updated_at spent_on (the date)
user_id --> points to USERS.id
created_at / updated_at
The two ideas that made everything click:
- A primary key (
id) is just a unique, auto-numbered tag for each row. Names repeat, IDs don't. - A foreign key (
user_id) is a column that stores another table's id. That's the whole magic of relational databases — Azhar's expenses all carry hisuser_id, so the DB always knows whose lunch was whose.
Sounds obvious now. Was NOT obvious an hour earlier.
Step 2: Turn the drawing into models
This is models.py — basically my paper sketch, but in Python. One class = one table.
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import MetaData, String, DateTime, Numeric, Date, ForeignKey, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=NAMING_CONVENTION)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
expenses: Mapped[list["Expense"]] = relationship(back_populates="user")
class Expense(Base):
__tablename__ = "expenses"
id: Mapped[int] = mapped_column(primary_key=True)
amount: Mapped[Decimal] = mapped_column(Numeric(10, 2))
description: Mapped[str] = mapped_column(String(255))
spent_on: Mapped[date] = mapped_column(Date)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
user: Mapped["User"] = relationship(back_populates="expenses")
A few things I picked up here that felt like "real developer" moments:
-
Money is NEVER a float. Ever. Floats can't hold
0.10 + 0.20exactly [it comes out as 0.30000000000000004, I'm not kidding], so over enough expenses your totals drift. Store it asNumeric(10, 2)→ PythonDecimal. Exact, always. -
Every table gets
created_atandupdated_at. You will always, always want to know when something was made or changed. -
The type annotation controls nullability.
Mapped[str]= required.Mapped[str | None]= optional. Neat. - Those two
relationship(...)lines aren't columns — they're just Python convenience so I can writeuser.expensesand get a list back without writing a query. The actual link is still theuser_idforeign key.
Gotcha #2: I originally named the expense date column
date. Bad idea — naming a column the same as its Python type (date: Mapped[date]) confuses everything and breaks type inference. Renamed itspent_on. Moral: don't name your stuff after builtins.
Step 3: The connection [kept separate on purpose]
database.py — this is how to connect, deliberately kept apart from models.py [which is what the tables are]:
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./expenses.db")
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
engine = create_engine(DATABASE_URL, echo=True, connect_args=connect_args)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
Why the os.getenv dance instead of just hardcoding the path? Because future-me deploying to a real Postgres database will just set an environment variable and NOT have to change a single line of code. Same reason secrets live in .env. One source of truth. Also — echo=True prints every SQL statement the ORM generates, which is weirdly satisfying to watch.
Oh, and before creating any of this I added *.db and *.sqlite3 to .gitignore. The database file is local junk data — it has no business in my repo. [And yeah, same "before it exists" trick I used for .env — git can't accidentally track what it was already told to ignore.]
Step 4: Alembic and my first migration
Here's the part I was low-key scared of, and it turned out to be the coolest bit.
alembic init alembic # creates the migration setup
Then I pointed Alembic at my project in alembic/env.py — told it where my models live and where the database is:
from database import DATABASE_URL
from models import Base
config.set_main_option("sqlalchemy.url", DATABASE_URL)
target_metadata = Base.metadata
This is the important bit: Alembic's autogenerate works by comparing my models to the actual database and writing the difference. target_metadata is how it "sees" my models. Without it, it detects nothing.
I also added that NAMING_CONVENTION thing (up in models.py) BEFORE generating anything. Why bother? Because otherwise the database invents random names for your constraints/indexes — and SQLite often gives them no name at all. Then a future migration that needs to change one has nothing to grab onto. Set the convention early, and everything gets clean, predictable names like fk_expenses_user_id_users. Set-it-and-forget-it.
Then the magic command:
alembic revision --autogenerate -m "create users and expenses tables"
And it wrote the migration for me — two create_table calls, the foreign key, the indexes, plus a matching downgrade() that undoes it all. I read the whole file before running it [apparently this is a habit real teams enforce — autogenerate isn't perfect, so you always eyeball it first]. Looked right. So:
alembic upgrade head # actually builds the tables
alembic current # shows my revision with (head) = up to date
Watching the real CREATE TABLE SQL scroll past because of echo=True? Chef's kiss. That was the "oh, THIS is what an ORM does" moment.
Gotcha #3: My
-mmessage accidentally said "expense" (singular), so the migration filename said expense too — and I panicked thinking the tables were wrong. They weren't. The-mtext is just a label; the real table names come from__tablename__. Totally cosmetic.Gotcha #4: When I pasted my code into an email, Gmail helpfully turned
users.idinto a clickable linkhttp://users.id/. Nearly gave me a heart attack. It's just email being email — the real file was fine. Check your code in the editor, not in email.
Step 5: The payoff — write a row, read it back
from decimal import Decimal
from datetime import date
from sqlalchemy import select
from database import SessionLocal
from models import User, Expense
with SessionLocal() as session:
user = User(name="Azhar", email="azhar@example.com")
session.add(user)
session.commit() # NOW it's in the DB, and user.id gets assigned
session.add(Expense(
amount=Decimal("12.50"), description="Lunch",
spent_on=date.today(), user_id=user.id,
))
session.commit()
fetched = session.scalars(select(User)).first()
print(fetched.id, fetched.name, [(e.description, e.amount) for e in fetched.expenses])
And it printed my user and their lunch. My app remembers things now. 🎉
Couple of things worth knowing:
- Nothing actually hits the database until
commit(). That's also when the primary key gets assigned — which is exactly why I commit the user before the expense that needs itsid. -
Decimal("12.50")uses quotes on purpose.Decimal(12.50)[no quotes] would drag the float imprecision back in. Little detail, big deal for money. -
select(User)withsession.scalars(...)is the modern SQLAlchemy 2.0 way. If you seesession.query(...)in older tutorials, that's the legacy style.
Gotcha #5: Ran the script a second time and it blew up with an
IntegrityErroron the email. That's not a bug — it's myuniqueconstraint literally doing its job and refusing a duplicate. Honestly kind of reassuring to see it work.
Stuff I want to remember [the honest takeaways]
- Design on paper first. The code is just the drawing, translated.
- Money is
Decimal/Numeric, never float. Build it from a string. - Give every table
created_at/updated_at. - Never hardcode the database URL — read it from an env var.
- NEVER edit the database structure by hand. Models → migration, every single time.
- Always read an autogenerated migration before you run it.
- Set your naming convention before the first migration, not after.
- Virtual environments are per-project. Install in the right one. Freeze
requirements.txtafter every install. - Ignore
.dbfiles and secrets before they exist. And always commit your migration files — they're the schema's shared history. - Keep
echo=Trueon while learning so you can actually see the SQL. It demystifies everything.
Next up: Phase 2, where I turn all this into real API endpoints [POST an expense, GET the list, the whole CRUD gang]. If Phase 1 gave the app a memory, Phase 2 gives it a mouth. See you there.

Top comments (0)