Skip to content

Primary Keys and Column Types

A primary key uniquely identifies a row.

Most tables use an auto-incrementing integer id:

python
id = db.Column(db.Integer, primary_key=True)
  • db.Integer
  • db.String(length)
  • db.Text
  • db.Boolean
  • db.DateTime
  • db.Float
  • db.Numeric

Example:

python
created_at = db.Column(db.DateTime, nullable=False)
  • nullable=False (required)
  • unique=True (unique constraint)
  • index=True (create an index for faster lookups)

Example:

python
email = db.Column(db.String(120), unique=True, index=True, nullable=False)

You can set default values:

python
is_active = db.Column(db.Boolean, default=True, nullable=False)

For timestamps, you typically use a callable (not a fixed time):

python
import datetime
created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow, nullable=False)

Relationships depend on foreign keys:

python
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)

We’ll build relationships later in this phase.

In SQLAlchemy 2.0 style, Mapped[T] is not documentation — the compiler of your schema reads it:

models.py
class Note(db.Model):
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str]                    # NOT NULL
    body: Mapped[str | None]              # nullable
    views: Mapped[int] = mapped_column(default=0)

Measured schema:

columntypenullableprimary key
idINTEGERnoyes
titleVARCHARnono
bodyVARCHARyesno
viewsINTEGERnono
diagram Diagram mermaid

Adding | None is the whole difference between a column that must have a value and one that may not. It is easy to miss in review, and the database will enforce whichever you wrote.

An integer primary key is assigned by the database, which is why it does not exist until the row is written:

pk.py
u = User(name="ada", age=36)
db.session.add(u)
print(u.id)          # None    <- nothing has been INSERTed yet
db.session.commit()
print(u.id)          # 1       <- the database assigned it

Measured exactly that. Code that needs the id — to build a URL, to write a related row — must commit (or flush()) first.

SQLAlchemyuse fornote
String(n)short textthe length is enforced by most databases, ignored by SQLite
Textlong textno length limit
Integercounts, ids
Numeric(10, 2)moneyexact decimal arithmetic
Floatmeasurementsbinary floating point, do not use for currency
Booleanflagsstored as an integer in SQLite
DateTime(timezone=True)timestampsstore UTC, convert on display
constraints.py
name: Mapped[str] = mapped_column(sa.String(20), unique=True)
measured, inserting a duplicate
IntegrityError: UNIQUE constraint failed: user.name

An application check (“does this name exist?”) races with other requests — two can both find nothing and both insert. The database constraint is the only check that cannot be raced. Keep the friendly check for a good error message, and let the constraint be the guarantee.

sketch What the annotation compiles to p5.js
Mapped[T] is NOT NULL, Mapped[T | None] is nullable, and a primary key is assigned by the database on commit.
pch.quizTag pch.quizDefaultTitle
  1. What is the difference between title: Mapped[str] and body: Mapped[str | None]?

    pch.quizShowAnswer

    B — the first compiles to NOT NULL and the second allows NULL — Measured: title came out nullable=False and body nullable=True. The annotation drives the schema, so adding or omitting | None changes what the database enforces.

  2. After db.session.add(u) but before commit, what is u.id for an integer primary key?

    pch.quizShowAnswer

    B — None, because no INSERT has run and the database assigns the value — Measured None before commit and 1 after. Anything needing the id — a URL, a related row — must commit or flush first.

  3. Why store a currency amount in Numeric(10, 2) rather than Float?

    pch.quizShowAnswer

    B — Float is binary floating point and cannot represent decimal fractions exactly, so amounts drift — 0.1 + 0.2 is not 0.3 in binary floating point. Numeric maps to an exact decimal type and returns Decimal in Python. A rounding error in stored money cannot be corrected after the fact.

  4. Why rely on a UNIQUE constraint rather than checking for an existing row first?

    pch.quizShowAnswer

    B — two concurrent requests can both find nothing and both insert; only the database constraint cannot be raced — Measured the constraint firing as IntegrityError: UNIQUE constraint failed: user.name. Keep the application check for a good message, and let the constraint be the guarantee.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading