Skip to main content

πŸ—οΈ Defining Database Models

A model is the blueprint for one kind of thing your app remembers β€” a user, a post, an order. In this lesson you'll learn to shape those blueprints with columns and constraints, choose good primary keys, and connect models to one another through relationships, all in the modern SQLAlchemy 2.0 typed style.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Write a SQLAlchemy model with typed columns and the right constraints
  • Choose between integer, UUID, and composite primary keys
  • Set default values correctly, including dynamic and server-side defaults
  • Model one-to-many, many-to-many, and one-to-one relationships
  • Apply mixins and naming conventions to keep a growing model layer clean

Estimated Time: 35–45 minutes  β€’  Difficulty: Intermediate

Hands-on: Design the complete data model for a small blog.

In This Lesson

What a Model Is

A model is a Python class that maps to one database table. Each attribute of the class becomes a column, and each instance of the class becomes a row. Define the class once and every record of that type shares the same shape.

πŸ’‘ A useful analogy: A model is an architect's blueprint. The blueprint (the class) describes rooms, dimensions, and materials once; every house built from it (each row) has the same layout even though the families living inside differ. Get the blueprint right and thousands of well-formed records follow for free.

Good model design pays off for the life of the project. A missing constraint or a poorly chosen key is cheap to fix on day one and painful to fix once real data has piled up on top of it.

Anatomy of a Model

Here is a complete model in the modern SQLAlchemy 2.0 style, where the Python type annotation drives the column type. Mapped[str] means a non-null string; Mapped[str | None] means the column is nullable.

from datetime import datetime, timezone
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.extensions import db


class User(db.Model):
    __tablename__ = "users"                       # explicit table name

    id: Mapped[int] = mapped_column(primary_key=True)
    username: Mapped[str] = mapped_column(String(80), unique=True)
    email: Mapped[str] = mapped_column(String(120), unique=True, index=True)
    created_at: Mapped[datetime] = mapped_column(
        default=lambda: datetime.now(timezone.utc)
    )

    # A relationship, not a column β€” one user has many posts
    posts: Mapped[list["Post"]] = relationship(back_populates="author")

    def __repr__(self) -> str:
        return f"<User {self.username}>"

    def to_dict(self) -> dict:
        return {"id": self.id, "username": self.username, "email": self.email}

Every model is built from the same handful of parts:

  • Base class β€” inherit from db.Model.
  • Table name β€” set with __tablename__, or let SQLAlchemy derive one.
  • Columns β€” annotated attributes using mapped_column().
  • Relationships β€” links to other models via relationship().
  • Methods β€” __repr__ for readable debugging plus any helpers like to_dict().

πŸ’‘ Why prefer typed mappings?

The older id = db.Column(db.Integer, primary_key=True) style still works, but the Mapped[...] style gives you editor autocompletion, static type checking, and columns that read like ordinary Python attributes. This course uses the modern style throughout.

Columns & Constraints

A column has a type and optional constraints β€” rules the database enforces on every value. Choosing the right type (and the tightest reasonable constraints) is how you push data-integrity guarantees down into the database itself.

ConstraintMeaningExample
primary_keyUniquely identifies the rowid: Mapped[int] = mapped_column(primary_key=True)
nullableWhether NULL is allowed (annotation-driven)Mapped[str] = NOT NULL; Mapped[str | None] = nullable
uniqueNo two rows may share this valuemapped_column(String(120), unique=True)
indexBuilds an index for faster lookupsmapped_column(String(80), index=True)
defaultValue used when none is suppliedmapped_column(default=True)

Constraints can stack. This email column must be present, unique, and is indexed for the frequent "find user by email" query at login:

email: Mapped[str] = mapped_column(
    String(120),
    unique=True,
    index=True,
)

πŸ“– Common column types

Text-like: String(n) (bounded), Text (unbounded).
Numbers: Integer, Float, Numeric(10, 2) for exact money values.
Truth & time: Boolean, Date, DateTime.

Use Numeric, never Float, for currency β€” floating-point rounding will eventually cost someone a cent.

Primary Keys

Every table needs a primary key: a column (or set of columns) that uniquely identifies each row. You have three common choices.

Auto-incrementing integer (the default)

id: Mapped[int] = mapped_column(primary_key=True)

Simple, compact, and fast. This is the right choice the vast majority of the time.

UUID

import uuid
from sqlalchemy import String

id: Mapped[str] = mapped_column(
    String(36),
    primary_key=True,
    default=lambda: str(uuid.uuid4()),
)

UUIDs are unguessable and can be generated by many machines without collisions β€” handy for distributed systems or when you don't want to leak how many records exist by exposing sequential IDs in URLs.

Composite key

from sqlalchemy import ForeignKey

class Enrollment(db.Model):
    student_id: Mapped[int] = mapped_column(ForeignKey("students.id"), primary_key=True)
    course_id: Mapped[int] = mapped_column(ForeignKey("courses.id"), primary_key=True)
    enrolled_on: Mapped[datetime] = mapped_column(default=lambda: datetime.now(timezone.utc))

Two columns marked primary_key=True form a composite key: the pair must be unique. This pattern is common in the join tables that sit between many-to-many relationships.

Default Values

A default fills a column in when you don't supply a value. There is one subtlety that trips up nearly everyone.

Static defaults

is_active: Mapped[bool] = mapped_column(default=True)
role: Mapped[str] = mapped_column(String(20), default="user")

Dynamic defaults β€” pass a callable, not a call

# RIGHT β€” pass the function; it runs once per row at insert time
created_at: Mapped[datetime] = mapped_column(
    default=lambda: datetime.now(timezone.utc)
)

# WRONG β€” this calls the function ONCE at import time and freezes that
# single timestamp onto every future row.
# created_at: Mapped[datetime] = mapped_column(default=datetime.now(timezone.utc))

⚠️ The classic default bug

If you write default=datetime.now(timezone.utc) with the parentheses, Python evaluates it immediately, when the class is first defined. Every row then gets the exact same timestamp β€” the moment your app started. Always pass a callable (a bare function name or a lambda) so it runs fresh for each insert.

Python default vs. server default

from sqlalchemy import func

# Evaluated in Python when the object is created
created_at: Mapped[datetime] = mapped_column(default=lambda: datetime.now(timezone.utc))

# Evaluated by the DATABASE β€” good when other apps insert rows too
created_at: Mapped[datetime] = mapped_column(server_default=func.now())

Reach for server_default when rows might be inserted outside your Python code (a data-import script, another service) and you still want the default applied.

Model Relationships

Real data is connected: a user has posts, a post belongs to a category, students enroll in courses. SQLAlchemy lets you express these links as attributes so you can write user.posts instead of a hand-written JOIN.

erDiagram USER ||--o{ POST : writes STUDENT }o--o{ COURSE : enrolls USER ||--|| PROFILE : has

One-to-many (the workhorse)

The child table holds a foreign key pointing at the parent. The relationship is declared on both sides with back_populates.

from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship


class User(db.Model):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    username: Mapped[str] = mapped_column(String(80), unique=True)

    posts: Mapped[list["Post"]] = relationship(back_populates="author")


class Post(db.Model):
    __tablename__ = "posts"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(120))
    content: Mapped[str] = mapped_column(Text)

    # The foreign key column...
    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
    # ...and the object-level link back to the parent
    author: Mapped["User"] = relationship(back_populates="posts")

Now a_user.posts gives you a list of posts, and a_post.author gives you the user β€” no SQL in sight.

Many-to-many

When both sides can have many of the other, you need an association table in the middle. Students take many courses; each course has many students.

from sqlalchemy import Column, ForeignKey, Table

enrollments = Table(
    "enrollments",
    db.metadata,
    Column("student_id", ForeignKey("students.id"), primary_key=True),
    Column("course_id", ForeignKey("courses.id"), primary_key=True),
)


class Student(db.Model):
    __tablename__ = "students"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(80))
    courses: Mapped[list["Course"]] = relationship(
        secondary=enrollments, back_populates="students"
    )


class Course(db.Model):
    __tablename__ = "courses"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(120))
    students: Mapped[list["Student"]] = relationship(
        secondary=enrollments, back_populates="courses"
    )

πŸ’‘ When the link itself has data

If the relationship needs its own fields β€” an enrollment grade, an order quantity β€” promote the association table to a full model (the association object pattern) and give each side a one-to-many relationship to it.

One-to-one

A one-to-one is a one-to-many capped at one child. Set uselist=False so the attribute returns a single object rather than a list.

class User(db.Model):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    profile: Mapped["Profile"] = relationship(back_populates="user", uselist=False)


class Profile(db.Model):
    __tablename__ = "profiles"
    id: Mapped[int] = mapped_column(primary_key=True)
    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True)
    bio: Mapped[str | None] = mapped_column(Text)
    user: Mapped["User"] = relationship(back_populates="profile")

πŸ“– back_populates vs. backref

Older tutorials use backref, which auto-creates the reverse side. Modern SQLAlchemy recommends back_populates, where you write the relationship explicitly on both models. It's a little more typing but far clearer to read β€” you can see the whole relationship without guessing what was auto-generated.

Mixins & Best Practices

As the model layer grows, a few habits keep it maintainable.

Share common columns with a mixin

Many tables want created_at and updated_at timestamps. Rather than copy them everywhere, define them once in a mixin and inherit it.

from datetime import datetime, timezone
from sqlalchemy.orm import Mapped, mapped_column


class TimestampMixin:
    created_at: Mapped[datetime] = mapped_column(
        default=lambda: datetime.now(timezone.utc)
    )
    updated_at: Mapped[datetime] = mapped_column(
        default=lambda: datetime.now(timezone.utc),
        onupdate=lambda: datetime.now(timezone.utc),
    )


class Post(TimestampMixin, db.Model):
    __tablename__ = "posts"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(120))
    # created_at and updated_at come from the mixin for free

βœ… Model design do's and don'ts

  • Do name model classes with singular nouns (User, not Users).
  • Do use snake_case for columns (first_name, not firstName).
  • Do index foreign keys and any column you filter or sort on often.
  • Don't over-index β€” every index slows writes and costs storage.
  • Don't use Float for money; use Numeric.
  • Don't forget nullable/unique constraints β€” they are your cheapest data-integrity guarantee.

Hands-on Exercise

πŸ‹οΈ Design a Blog's Data Model

Objective: Turn a plain-English description into a set of well-formed models.

Requirements:

  1. A User has a unique username and email, and writes many posts.
  2. A Post has a title, body, a status ("draft" / "published"), belongs to one author, and carries created/updated timestamps.
  3. A Tag has a unique name, and a post can have many tags while a tag can label many posts (many-to-many).
  4. Index the columns you'd query most and reuse a timestamp mixin.
πŸ’‘ Hint

Posts↔Tags is many-to-many, so you need a Table association object with two foreign keys. Userβ†’Post is one-to-many, so the foreign key lives on Post. Put the timestamps in a mixin so both User and Post can share them.

βœ… Sample solution
from datetime import datetime, timezone
from sqlalchemy import Column, ForeignKey, String, Table, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.extensions import db


class TimestampMixin:
    created_at: Mapped[datetime] = mapped_column(
        default=lambda: datetime.now(timezone.utc)
    )
    updated_at: Mapped[datetime] = mapped_column(
        default=lambda: datetime.now(timezone.utc),
        onupdate=lambda: datetime.now(timezone.utc),
    )


post_tags = Table(
    "post_tags",
    db.metadata,
    Column("post_id", ForeignKey("posts.id"), primary_key=True),
    Column("tag_id", ForeignKey("tags.id"), primary_key=True),
)


class User(TimestampMixin, db.Model):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    username: Mapped[str] = mapped_column(String(80), unique=True, index=True)
    email: Mapped[str] = mapped_column(String(120), unique=True, index=True)
    posts: Mapped[list["Post"]] = relationship(back_populates="author")


class Post(TimestampMixin, db.Model):
    __tablename__ = "posts"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(120), index=True)
    body: Mapped[str] = mapped_column(Text)
    status: Mapped[str] = mapped_column(String(20), default="draft", index=True)

    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
    author: Mapped["User"] = relationship(back_populates="posts")
    tags: Mapped[list["Tag"]] = relationship(
        secondary=post_tags, back_populates="posts"
    )


class Tag(db.Model):
    __tablename__ = "tags"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(40), unique=True)
    posts: Mapped[list["Post"]] = relationship(
        secondary=post_tags, back_populates="tags"
    )

🎯 Quick Quiz

Question 1: In the modern typed style, how do you make a column nullable?

Question 2: Why should a datetime default be written default=lambda: datetime.now(timezone.utc) rather than default=datetime.now(timezone.utc)?

Question 3: A blog post can have many tags and a tag can label many posts. Which relationship type is this?

Summary & Quiz

πŸŽ‰ Key Takeaways

  • A model is a class mapping to a table; attributes are columns, instances are rows.
  • Modern typed mappings (Mapped[...] + mapped_column()) drive column types and nullability from annotations.
  • Push data integrity into the database with constraints: unique, index, nullability, and primary keys.
  • Always pass a callable for dynamic defaults so each row gets a fresh value.
  • Model connections with relationships β€” one-to-many, many-to-many, one-to-one β€” and prefer back_populates.

πŸ“š Further Reading

πŸš€ What's Next?

Your tables are designed and connected. Next you'll bring them to life with CRUD operations β€” creating, reading, updating, and deleting rows through the SQLAlchemy session.

πŸŽ‰ Blueprints in hand!

You can shape any data your app needs. Time to start filling those tables with rows.