ποΈ 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 liketo_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.
| Constraint | Meaning | Example |
|---|---|---|
primary_key | Uniquely identifies the row | id: Mapped[int] = mapped_column(primary_key=True) |
nullable | Whether NULL is allowed (annotation-driven) | Mapped[str] = NOT NULL; Mapped[str | None] = nullable |
unique | No two rows may share this value | mapped_column(String(120), unique=True) |
index | Builds an index for faster lookups | mapped_column(String(80), index=True) |
default | Value used when none is supplied | mapped_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.
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, notUsers). - Do use
snake_casefor columns (first_name, notfirstName). - 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
Floatfor money; useNumeric. - Don't forget
nullable/uniqueconstraints β 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:
- A
Userhas a unique username and email, and writes many posts. - A
Posthas a title, body, astatus("draft" / "published"), belongs to one author, and carries created/updated timestamps. - A
Taghas a unique name, and a post can have many tags while a tag can label many posts (many-to-many). - 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
- Flask-SQLAlchemy β Models & Tables
- SQLAlchemy 2.0 β Relationship Patterns
- SQLAlchemy 2.0 β Declarative Table Configuration
π 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.