Skip to content

Schema

Table and View

>>> from d2 import Table, View, Field, PrimaryKey, ForeignKey, Unique, Index, field, TableMeta, db
>>> from d2.model import IndexDef
>>> class MyTable(Table):
... id: PrimaryKey[int]
...
>>> class MyView(View):
... id: Field[int]
...

Table supports SELECT, INSERT, UPDATE, and DELETE.
View is read-only (SELECT only).

Both use D2Meta as their metaclass, which on class creation:

  1. Parses type annotations to build Field proxies
  2. Infers the table name from the class name (CamelCase → snake_case, strips trailing Model)
  3. Registers the class in the global model registry used by migrations

Field types

AnnotationSQL meaning
Field[T]Plain column
Column[T]Alias for Field[T]
PrimaryKey[T]Column with PRIMARY KEY
Unique[T]Column with UNIQUE constraint
Index[T]Column with an index (no unique constraint)

Field[T | None] (or Field[Optional[T]]) marks the column as nullable.

Enum classes work as column types: str-based enums (StrEnum, class X(str, Enum)) are stored as TEXT, int-based enums (IntEnum) as INTEGER. Values travel on the wire as their base str/int value.

>>> class Article(Table):
... id: PrimaryKey[int] # primary key
... slug: Unique[str] # unique column
... views: Index[int] # indexed column
... title: Field[str] # plain column
... summary: Field[str | None] # nullable column
...

field() helper

Use field() as the class-level default to attach metadata that annotations alone cannot express:

>>> class Post(Table):
... id: PrimaryKey[int] = field(default=db.serial())
... title: Field[str]
...
>>> class Comment(Table):
... id: PrimaryKey[int] = field(default=db.serial())
... post_id: ForeignKey[Post] = field(on_delete=db.CASCADE)
... body: Field[str]
... slug: Field[str] = field(name="url_slug")
... flagged: Unique[str] = field(unique=True)
...

field() parameters:

ParameterTypeDescription
defaultDbExpr | NoneDB-level default (e.g. db.serial(), db.now())
on_deleteReferentialAction | NoneFK delete action (db.CASCADE, db.SET_NULL, etc.)
on_updateReferentialAction | NoneFK update action
namestr | NoneOverride the column name in the database
uniqueboolAdd a UNIQUE constraint
indexboolCreate an index on this column

TableMeta

TableMeta is set as __meta__ on the class and controls table-level options:

>>> class Order(Table):
... __meta__ = TableMeta(
... table="orders",
... schema="commerce",
... indexes=(
... IndexDef(
... columns=("user_id", "created_at"),
... name="idx_orders_user_date",
... unique=False,
... method="BTREE",
... ),
... ),
... extensions=("uuid-ossp",),
... )
... id: PrimaryKey[int] = field(default=db.serial())
... user_id: Field[int]
... created_at: Field[str]
...

TableMeta fields:

FieldTypeDescription
tablestr | NoneOverride table name (default: snake_case of class name)
schemastr | NonePostgres schema
indexestuple[IndexDef, ...]Composite or method-specific indexes
foreign_keystuple[ForeignKey, ...]Table-level FKs (rarely needed; prefer ForeignKey[T] annotation)
extensionstuple[str, ...]Extensions to ensure exist

ForeignKey

Declare foreign keys using the ForeignKey[T] type annotation, where T is the referenced Table class. Use field(on_delete=...) to set the referential action.

>>> class User(Table):
... id: PrimaryKey[int] = field(default=db.serial())
...
>>> class Profile(Table):
... id: PrimaryKey[int] = field(default=db.serial())
... user_id: ForeignKey[User] = field(on_delete=db.CASCADE)
...
>>> class Audit(Table):
... id: PrimaryKey[int] = field(default=db.serial())
... user_id: ForeignKey[User] = field(on_delete=db.SET_NULL)
...

Referential actions are constants on the db module: db.CASCADE, db.SET_NULL, db.RESTRICT, db.NO_ACTION, db.SET_DEFAULT.

IndexDef

>>> IndexDef(
... columns=("col_a", "col_b"),
... name="idx_my_table_ab",
... unique=False,
... method="GIN",
... )
IndexDef(columns=('col_a', 'col_b'), name='idx_my_table_ab', unique=False, method='GIN', where=None)

where declares a partial index — the predicate is emitted verbatim as CREATE [UNIQUE] INDEX ... WHERE <predicate>:

>>> IndexDef(
... columns=("email",),
... name="uq_users_email_active",
... unique=True,
... where="deleted_at IS NULL",
... )
IndexDef(columns=('email',), name='uq_users_email_active', unique=True, method=None, where='deleted_at IS NULL')

Table name inference

d2 derives the table name from the class name automatically:

Class nameInferred table name
Useruser
BlogPostblog_post
UserModeluser (strips Model suffix)

Override with TableMeta(table="my_name").

Schema inference from module path

If your models live in a package that contains models in the path, d2 infers the schema from the parent package:

myapp/
commerce/
models.py → schema="commerce"
auth/
models.py → schema="auth"

Override with TableMeta(schema="...").

View with a query

Views can be backed by a d2 query using the query= class keyword:

>>> class ActiveUser(View, query=(
... User
... .select(User.id.aliased("id"))
... .where(User.id.isnotnull())
... )):
... id: Field[int]
...

d2 validates at class-creation time that the view’s declared columns match the query’s projected columns (name and type). This raises TypeError immediately if they diverge.