← FastAPI 04 / 18 Next: Alembic β†’

Databases & ORM

Module 2 · Backend · ⏱ ~35 min
Why this matters: All booking data, shop info, and admin accounts live in PostgreSQL. SQLAlchemy is the Python library that lets us read and write that data without writing raw SQL. Every await session.execute(...) in the code is a database query.

What Is a Relational Database?

Think of a database as a collection of spreadsheets, where each spreadsheet is a "table". Each row is one record; each column is a piece of data. Tables can reference each other using IDs.

shops table:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ id (UUID)                        β”‚ name_en            β”‚ category β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ fa2fd810-04a8-41f7-bcc1-dcb3...  β”‚ BKK Sports Club    β”‚ courts   β”‚
β”‚ 9b3c1234-...                     β”‚ Paw & Groom        β”‚ grooming β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

bookings table:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ id               β”‚ shop_id (FK β†’ shops.id)          β”‚ status   β”‚ customer_name      β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ 705f95b6-...     β”‚ fa2fd810-04a8-41f7-bcc1-dcb3...  β”‚ confirmedβ”‚ Test User          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The shop_id in bookings REFERENCES the id in shops.
This is a FOREIGN KEY β€” it links rows in different tables.

Primary Keys and Foreign Keys

Why UUIDs Instead of Auto-Increment IDs?

Auto-increment IDs (1, 2, 3...) are simple, but have problems:

UUIDs like 705f95b6-010f-4038-adf1-427f1e0f5527 are random 128-bit numbers. The chance of collision is astronomically small. You can generate them anywhere (no database round-trip), and they reveal no information about the count of records.

What Is an ORM?

ORM = Object Relational Mapper. It maps Python classes to database tables, so you work with Python objects instead of writing raw SQL strings.

Without ORM (raw SQL)With SQLAlchemy ORM
"SELECT * FROM bookings WHERE id = ?" select(Booking).where(Booking.id == id)
SQL injection risk if you concatenate strings Parameters are always safely escaped
Returns raw tuples β€” you map them manually Returns Python objects with attributes

SQLAlchemy 2.x Models

Each database table is defined as a Python class. Here's the Booking model from models/booking.py:

python β€” backend/app/models/booking.py
class Booking(Base):
    __tablename__ = "bookings"           # the actual DB table name
    __table_args__ = (
        CheckConstraint(                 # database-level enum validation
            "status IN ('pending_payment', 'confirmed', 'cancelled')",
            name="chk_booking_status",
        ),
    )

    # Mapped[T] says "this column holds type T"
    id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
    shop_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("shops.id"), nullable=False)
    resource_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("resources.id"), nullable=False)
    service_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("services.id"))
    customer_name: Mapped[str] = mapped_column(String(200), nullable=False)
    customer_phone: Mapped[str] = mapped_column(String(20), nullable=False)
    customer_line_user_id: Mapped[str | None] = mapped_column(String(100))  # optional
    booking_date: Mapped[date] = mapped_column(Date, nullable=False)
    start_time: Mapped[time] = mapped_column(Time, nullable=False)
    end_time: Mapped[time] = mapped_column(Time, nullable=False)
    duration_min: Mapped[int] = mapped_column(SmallInteger, nullable=False)
    total_price: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
    status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending_payment")
    omise_charge_id: Mapped[str | None] = mapped_column(String(100))
    # reminder flags β€” prevent sending the same notification twice
    reminder_24h_sent: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
    reminder_2h_sent: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
    confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))

    # Relationships β€” SQLAlchemy can load related objects automatically
    shop: Mapped["Shop"] = relationship(back_populates="bookings")
    resource: Mapped["Resource"] = relationship(back_populates="bookings")
    service: Mapped["Service | None"] = relationship(back_populates="bookings")

Key points:

The Many-to-Many Junction Table

A staff member (Resource) can offer many Services. A Service can be offered by many staff members. This is a Many-to-Many relationship, which requires a junction table:

python β€” backend/app/models/resource.py
# The junction table β€” just two columns, both are foreign keys
resource_services = Table(
    "resource_services",
    Base.metadata,
    Column("resource_id", ForeignKey("resources.id", ondelete="CASCADE"), primary_key=True),
    Column("service_id", ForeignKey("services.id", ondelete="CASCADE"), primary_key=True),
)
# ondelete="CASCADE" means: if a resource is deleted, also delete its rows in resource_services
# primary_key=True on both columns = composite primary key (the pair must be unique)

class Resource(Base):
    ...
    services: Mapped[list["Service"]] = relationship(
        secondary=resource_services,  # uses the junction table
        back_populates="resources"
    )
resources table          resource_services          services table
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ id       β”‚ name   β”‚   β”‚resource_id β”‚ service_id β”‚   β”‚ id       β”‚ name_en         β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€   β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€   β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ staff_1  β”‚Khun Nidβ”‚   β”‚ staff_1    β”‚ svc_bath   β”‚   β”‚ svc_bath β”‚ Bath & Blow Dry β”‚
β”‚ staff_2  β”‚Khun Toyβ”‚   β”‚ staff_1    β”‚ svc_groom  β”‚   β”‚ svc_groomβ”‚ Full Groom      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚ staff_2    β”‚ svc_bath   β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                         β”‚ staff_2    β”‚ svc_groom  β”‚
                         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Both Khun Nid and Khun Toy offer both Bath and Full Groom.

Async Session and Queries

The database session is the connection handle for queries. From database.py:

python β€” backend/app/database.py
engine = create_async_engine(settings.DATABASE_URL, echo=False)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)

async def get_db() -> AsyncGenerator[AsyncSession, None]:
    async with AsyncSessionLocal() as session:
        yield session
        # session automatically closes when the context exits

Querying with the session:

python β€” query patterns used in the project
# SELECT one row or None
resource = (await session.execute(
    select(Resource).where(Resource.id == resource_id, Resource.is_active == True)
)).scalar_one_or_none()

# SELECT all matching rows
bookings = (await session.execute(
    select(Booking)
    .where(Booking.shop_id == admin.shop_id)
    .order_by(Booking.booking_date, Booking.start_time)
)).scalars().all()

# INSERT
session.add(booking)     # stages the insert
await session.commit()   # executes all staged changes in a transaction
await session.refresh(booking)  # re-reads the row to get server-generated values (like created_at)

# UPDATE
booking.status = "confirmed"
booking.confirmed_at = datetime.now(timezone.utc)
await session.commit()  # the UPDATE is sent to the DB here

flush() vs commit()

In the seed script, flush() was used to get the shop's UUID before creating its ShopAdmin:

python β€” why flush() is needed
session.add(bkk)
await session.flush()    # sends INSERT, DB assigns bkk.id, but doesn't commit yet
# Now bkk.id is available:
session.add(ShopAdmin(shop_id=bkk.id, email="bkk@demo.com", ...))
# Later:
await session.commit()   # both bkk and its admin are committed together

🧠 Self-Check Quiz

1. What is a foreign key and what does the database do when you try to insert a booking with a shop_id that doesn't exist in the shops table?

A foreign key is a column that references the primary key of another table. If you try to insert a booking with a shop_id that doesn't exist in shops, the database rejects the INSERT with a foreign key constraint violation error. This prevents "orphan" records β€” bookings that reference non-existent shops.

2. Why does JongYang use UUID primary keys instead of auto-increment integers?

Three reasons: (1) Security β€” auto-increment IDs are predictable; users could enumerate all bookings by trying 1, 2, 3... UUIDs are random and reveal nothing about total record count. (2) Distributed safety β€” UUIDs can be generated anywhere without a database round-trip; auto-increments need the DB sequence. (3) Merge safety β€” if you ever merge databases, UUIDs don't collide.

3. What is the resource_services table and why does it have two primary key columns?

It's a junction table implementing a Many-to-Many relationship between resources (staff) and services. Two primary key columns form a "composite primary key" β€” the combination of (resource_id, service_id) must be unique. This prevents the same staff member from being linked to the same service twice. The table has no other columns β€” its entire purpose is to record which staff offer which services.

4. What's the difference between session.flush() and session.commit()?

flush() sends the SQL to the database but keeps the transaction open β€” the changes are visible within this session but not yet visible to other connections. Use it when you need a server-generated value (like an auto-UUID or server_default) before continuing. commit() finalizes the transaction β€” changes become permanent and visible to all connections. If you call commit() without flush(), SQLAlchemy automatically flushes first.

5. Why is total_price stored as Numeric(10, 2) in the database instead of Float?

Float uses binary floating-point arithmetic, which can't represent most decimal fractions exactly. For example, 0.1 + 0.2 = 0.30000000000000004 in float. Money requires exact decimal arithmetic. NUMERIC(10, 2) stores numbers with exactly 2 decimal places using base-10 arithmetic β€” 150.00 is always exactly 150.00. Pydantic's Decimal type in Python also avoids float arithmetic for the same reason.