Skip to main content

SQLModel

Follow the SQLModel + FastAPI tutorial pattern: each aggregate module defines a Base data class plus table / Create / Read (and sometimes Update) variants.

  • <Name>Base(SQLModel): data-only. Fields shared by create input and read output. Excludes id, server-derived fields, and sa_column directives.
  • <Name>(<Name>Base, table=True): the SQLAlchemy table. Adds id, relationships, server-derived columns, __table_args__, and sa_column overrides.
  • <Name>Create(<Name>Base): usually just pass. If Create needs to omit fields, those fields don't belong in Base, move them to the table class.
  • <Name>Read(<Name>Base): adds id: int (required) and any server-derived field readers should see.
  • <Name>Update(SQLModel): partial update. Inherits from SQLModel directly (every field becomes optional). Only add when the resource supports PATCH.

TL;DR Example

The example below has one column per shape:

ColumnGroupSQL NullableTypePydantic DefaultRemarks
id5FalseintSQL sequence
a1Falseint-
b2Falseint0
c3Trueint | NoneNone
d4Trueint | None0
e1Falsenaive datetime-
f2Falsenaive datetimedatetime.utcnow
g3Truenaive datetime | NoneNone
h4Truenaive datetime | Nonedatetime.utcnow
i1Falsetimezone-aware datetime-
j2Falsetimezone-aware datetimedatetime.utcnow
k3Truetimezone-aware datetime | NoneNone
l4Truetimezone-aware datetime | Nonedatetime.utcnow
m1FalseDecimal-
n2FalseDecimal0
o3TrueDecimal | NoneNone
p4TrueDecimal | None0
q1FalseEnum-
r2FalseEnumDraft
s3TrueEnum | NoneNone
t4TrueEnum | NoneDraft
u1Falseint-Required foreign key
v4Trueint | NoneNoneOptional foreign key
w1Falseint-Required self-referential foreign key
x4Trueint | NoneNoneOptional self-referential foreign key
import enum
from datetime import UTC, datetime
from decimal import Decimal
from typing import TYPE_CHECKING, Optional

import sqlmodel
from sqlmodel import Column, DateTime, Field, Relationship, SQLModel, UniqueConstraint

if TYPE_CHECKING:
from myapp.schemas.fxample import Fxample


class ExampleStatus(enum.Enum):
Draft = "Draft"
Active = "Active"
Archived = "Archived"


class ExampleBase(SQLModel):
# int across 4 groups
a: int
b: int = 0
c: int | None = None
d: int | None = 0

# naive datetime across 4 groups
e: datetime
f: datetime = Field(default_factory=datetime.now)
g: datetime | None = None
h: datetime | None = Field(default_factory=datetime.now)

# timezone-aware datetime across 4 groups (table class re-declares with sa_column)
# `sa_column` bypasses nullability inference, so should explicitly add `nullable=`.
i: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=False))
j: datetime = Field(
default_factory=lambda: datetime.now(UTC),
sa_column=Column(DateTime(timezone=True), nullable=False),
)
k: datetime | None = Field(
default=None, sa_column=Column(DateTime(timezone=True), nullable=True)
)
l: datetime | None = Field(
default_factory=lambda: datetime.now(UTC),
sa_column=Column(DateTime(timezone=True), nullable=True),
)

# Decimal across 4 groups (default mapping to NUMERIC; no sa_column override needed)
m: Decimal
n: Decimal = Decimal(0)
o: Decimal | None = None
p: Decimal | None = Decimal(0)

# Enum across 4 groups
# `sa_column` bypasses nullability inference, so should explicitly add `nullable=`.
q: ExampleStatus = Field(
sa_column=Column(sqlmodel.Enum(ExampleStatus), nullable=False),
)
r: ExampleStatus = Field(
default=ExampleStatus.Draft,
sa_column=Column(sqlmodel.Enum(ExampleStatus), nullable=False),
)
s: ExampleStatus | None = Field(
default=None,
sa_column=Column(sqlmodel.Enum(ExampleStatus), nullable=True),
)
t: ExampleStatus | None = Field(
default=ExampleStatus.Draft,
sa_column=Column(sqlmodel.Enum(ExampleStatus), nullable=True),
)

# Foreign keys
u: int = Field(foreign_key="fxample.id")
v: int | None = Field(default=None, foreign_key="fxample.id")

# Self-referential foreign keys
w: int = Field(foreign_key="example.id")
x: int | None = Field(default=None, foreign_key="example.id")


class Example(ExampleBase, table=True):
__tablename__ = "example"
__table_args__ = (UniqueConstraint("a", "b", name="example_a_b_key"),)

id: int | None = Field(primary_key=True, default=None)

# Relationships live only on the table class.
# When multiple FKs point to the same table, `foreign_keys` must disambiguate.
fxample: "Fxample" = Relationship(
back_populates="examples",
sa_relationship_kwargs={"foreign_keys": "[Example.u]"},
)
fxample_optional: Optional["Fxample"] = Relationship(
back_populates="examples_optional",
sa_relationship_kwargs={"foreign_keys": "[Example.v]"},
)
parent: "Example" = Relationship(
back_populates="children",
sa_relationship_kwargs={
"remote_side": "Example.id",
"foreign_keys": "[Example.w]",
},
)
parent_optional: Optional["Example"] = Relationship(
back_populates="children_optional",
sa_relationship_kwargs={
"remote_side": "Example.id",
"foreign_keys": "[Example.x]",
},
)
children: list["Example"] = Relationship(
back_populates="parent",
sa_relationship_kwargs={"foreign_keys": "[Example.w]"},
)
children_optional: list["Example"] = Relationship(
back_populates="parent_optional",
sa_relationship_kwargs={"foreign_keys": "[Example.x]"},
)


class ExampleCreate(ExampleBase):
pass


class ExampleRead(ExampleBase):
# Re-defined all required fields that have default value (group 2)
# because defining default values make the field not required in Pydantic, but once
# the value is populated, they are guarenteed to present

b: int

f: datetime

j: datetime

n: Decimal

r: ExampleStatus

id: int # Including the primary key


class ExampleUpdate(SQLModel): # Note that this does NOT inherit from ExampleBase
# all fields are customizable / optional, defined only the fields that are allowed to modify
a: int | None = None
b: int | None = None
e: datetime | None = None
f: datetime | None = None
i: datetime | None = None
j: datetime | None = None
q: ExampleStatus | None = None
r: ExampleStatus | None = None

Assumptions

This section outlines all the assumptions made when creating the above example. Also explains some key principles.

The purpose of NULL

  • 99% of the time, NULL should be treated as "absence of a value"; "We don't know/care".
  • ❌ Using NULL as a stand-in for a specific named state ("never expires", "approved", "inherit")
  • ✅ Name the state with an enum, sentinel, or boolean-plus-value instead.
  • It makes total sense for a client to explicitly supply NULL when they mean "no value", while leaving some other fields absent because they don't care, want to use default.

Nullability & Default

  • Nullability

a statement about whether a column accept "no value" as a legitimate value.

  • Default

a convenience for the writer, fires only when the field is absent. It does not imply nullability.

  • All defaults are applied at Pydantic level, rather than SQL level.
  • In SQL, nullability & default are independent concepts.
  • In Pydantic, nullability is strictly controlled by default value: "have default" = "not required", and vice versa.
  • When a field is not required, but has no default, it will implicitly be NULL in SQL = None when populate back.
  • To achieve "Required, but still have default", move to default to the <Name>Create subclass.

According to the above, here's all the possible group combinations:

GroupPydantic typesPydantic defaultSQL nullabilitySQL defaultExample
1TRequired, no defaultNullable=FalseNo defaultName
2TRequired, with defaultNullable=FalseNo defaultImportant Timestamp (default_factory = now)
3T | NoneNot required, no defaultNullable=TrueNo defaultDescription (implicit default = None)
4T | NoneNot required, with defaultNullable=TrueNo defaultStatus (default = Draft, but None is accepted)
5TRequired, no defaultNullable=FalseWith defaultAuto increase primary key
-TRequired, with defaultNullable=FalseWith default(N/A)
-T | NoneNot required, no defaultNullable=TrueWith default(N/A)
-T | NoneNot required, with defaultNullable=TrueWith default(N/A)

Primitive types

SQLModel auto-maps these Python types to a SQL column type, so they can be declared plainly in Base without an sa_column override:

PythonSQL (default)
intINTEGER
strVARCHAR
floatFLOAT
boolBOOLEAN
bytesLargeBinary
DecimalNUMERIC
datetime.dateDATE
datetime.timeTIME
datetime.datetimeDATETIME (naive — no timezone)
datetime.timedeltaInterval
uuid.UUIDUUID (Postgres) / CHAR(32) elsewhere
enum.Enum subclassEnum

Anything else — or any case where the default mapping is wrong (timezone-aware datetime, JSONB instead of JSON, String(255) vs default String, custom Pydantic types, Postgres-only types like ARRAY / INET) — needs an sa_column=Column(...) re-declaration on the table class.