Examples#

Canonical runnable examples live under examples/ and are included here with literalinclude so the docs stay in sync with code that CI executes (just examples).

whenever + TypeID + SQLite#

End-to-end script: init SQLite, define a TypeID-backed model with a whenever.PlainDateTime field, create tables, save, and round-trip.

from sqlmodel import SQLModel
from whenever import Instant, PlainDateTime

import activemodel
from activemodel import BaseModel
from activemodel.mixins import TypeIDPrimaryKey
from activemodel.session_manager import get_engine

# Although this looks like it's an absolute path, it is translated into a relative path based on the source location of this file
activemodel.init("sqlite:///database.db")


class User(
    BaseModel,
    # wire this model into the DB, without this alembic will not generate a migration
    table=True,
):
    # you can use a different pk type, but why would you?
    id: str = TypeIDPrimaryKey("user")
    # PlainDateTime matches SQLite behavior: no timezone support
    booked_date: PlainDateTime


# This magic command enables you to avoid the need to run or manage migrations and just magically creates all the tables in the local database
SQLModel.metadata.create_all(get_engine())

# SQLite/stdlib datetimes are microsecond-precision; truncate nanoseconds for a clean round-trip
now_in_sys_time = PlainDateTime(Instant.now().to_system_tz().to_plain().to_stdlib())

user = User(booked_date=now_in_sys_time).save()
fresh_user = User.one(user.id)

assert fresh_user.booked_date == now_in_sys_time

Run it locally:

just examples
# or: uv run python examples/whenever_typeid_and_sqlite.py

Advanced Field Types and Configuration#

activemodel leverages SQLModel and SQLAlchemy to support complex column types, constraints, and schema configurations.

Custom SQLAlchemy Types and Datetimes#

You can map specific fields to underlying SQLAlchemy types using sa_type. This is especially useful for timezone-aware datetimes and advanced PostgreSQL types.

import sqlalchemy as sa
from typing import Tuple
from datetime import datetime
from sqlmodel import Field
from activemodel import BaseModel
from sqlalchemy_postgres_point import PointType

class LocationEvent(BaseModel, table=True):
    # Timezone-aware datetimes using sa_type
    booked_datetime: datetime = Field(
        sa_type=sa.DateTime(timezone=True)  # type: ignore
    )

    # Custom PostgreSQL Types (e.g. Point)
    location: Tuple[float, float] | None = Field(
        default=None,
        sa_type=PointType
    )

Defaults, Exclusions, and Constraints#

Control schema visibility and enforce constraints directly within the Field definition.

from sqlmodel import Field
from activemodel import BaseModel

class Product(BaseModel, table=True):
    # Require values greater than zero
    price_cents: int = Field(gt=0)

    # Exclude internal fields from Pydantic schemas (e.g. FastAPI responses)
    stripe_account_id: str | None = Field(default=None, exclude=True)

    # Define max lengths and index the column
    status: str = Field(default="active", max_length=100, index=True)

Structured JSON with Pydantic Models#

Using the PydanticJSONMixin, you can store complex Pydantic models or lists of models in PostgreSQL JSONB columns. The mixin handles serialization and deserialization automatically.

Single Model Column#

from pydantic import BaseModel as PydanticBaseModel
from sqlalchemy.dialects.postgresql import JSONB
from sqlmodel import Field
from activemodel import BaseModel
from activemodel.mixins import PydanticJSONMixin, TypeIDMixin, TimestampsMixin

class UserActionAuditData(PydanticBaseModel):
    ip_address: str
    build_version: str
    timestamp: str

class RefundChoice(
    BaseModel,
    TimestampsMixin,
    PydanticJSONMixin,
    TypeIDMixin("rc"),
    table=True,
):
    chosen_info: UserActionAuditData | None = Field(default=None, sa_type=JSONB)
    "audit data about the user's choice stored as structured JSON"

Plain JSON Array with Server Default#

For simple arrays of primitives, you can store them directly as JSONB without a Pydantic wrapper. Use sa_column_kwargs to set a server-side default so the column is initialized to [] at the database level even for rows inserted outside of Python.

import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
from sqlmodel import Field
from activemodel import BaseModel
from activemodel.mixins import TypeIDMixin

class StreamSession(BaseModel, TypeIDMixin("ss"), table=True):
    streaming_ip_requests: list[str] = Field(
        sa_type=JSONB,
        default_factory=list,
        sa_column_kwargs={"server_default": sa.text("'[]'")},
    )

List of Models Column#

You can also store a list of Pydantic models. This is useful for things like transcripts, audit logs, or line items.

from pydantic import BaseModel as PydanticBaseModel
from sqlalchemy.dialects.postgresql import JSONB
from sqlmodel import Field
from activemodel import BaseModel
from activemodel.mixins import PydanticJSONMixin, TypeIDMixin

class TranscriptEntry(PydanticBaseModel):
    speaker: str
    content: str
    startTime: int  # in milliseconds
    endTime: int    # in milliseconds

class AIVisitTranscript(
    BaseModel,
    PydanticJSONMixin,
    TypeIDMixin("ai_vt"),
    table=True,
):
    # The mixin will convert this list of dicts to a list of TranscriptEntry objects
    transcript_data: list[TranscriptEntry] = Field(sa_type=JSONB)

Lifecycle Hooks#

activemodel supports several lifecycle hooks that allow you to execute logic at specific points in a model’s lifecycle.

Basic Validation and Defaults#

from datetime import datetime, timedelta
from activemodel import BaseModel
from activemodel.mixins import TypeIDMixin, TimestampsMixin
from sqlmodel import Field

class Screening(BaseModel, TimestampsMixin, TypeIDMixin("scr"), table=True):
    funding_goal: int = Field(default=0)
    funding_ending_at: datetime | None = Field(default=None)

    def before_create(self):
        """Set a default ending date only on creation."""
        if self.funding_ending_at is None:
            self.funding_ending_at = datetime.now() + timedelta(days=30)

    def before_save(self):
        """Prevent saving invalid data."""
        if self.funding_goal < 0:
            raise ValueError("Funding goal must be non-negative.")

Advanced Hooks: Immutable Fields#

You can use before_save to enforce immutability or perform complex validations.

import hashlib
from activemodel import BaseModel
from activemodel.mixins import TypeIDMixin
from sqlmodel import Field

def hash_prompt(prompt: str) -> str:
    return hashlib.sha256(prompt.encode()).hexdigest()

class LLMResponse(BaseModel, TypeIDMixin("llr"), table=True):
    prompt: str
    prompt_hash: str | None = Field(default=None, nullable=False)

    def before_save(self):
        new_hash = hash_prompt(self.prompt)

        # Enforce that the prompt cannot be changed once saved
        if self.prompt_hash and self.prompt_hash != new_hash:
            raise ValueError("Prompts should never be modified once they are cached")

        self.prompt_hash = new_hash

Factories with Polyfactory#

activemodel integrates with polyfactory via ActiveModelFactory. This is powerful for generating complex, related test data automatically.

Complex Data Generation and Datetimes#

Factories can use BaseFactory.__faker__ for generating rich mock data and dynamic values using Use. We also recommend utilizing robust datetime libraries like whenever for generating timestamps.

from polyfactory import BaseFactory, Use
from whenever import Instant
from activemodel.pytest.factories import ActiveModelFactory
from app.models.user import User

class UserFactory(ActiveModelFactory[User]):
    # Static defaults
    status = "active"

    # Using Faker for random structured data
    name = BaseFactory.__faker__.name
    email = BaseFactory.__faker__.email

    # Using Use() to execute a function dynamically for each build
    # Combining with `whenever` for timezone-aware datetimes
    last_active_at = Use(
        lambda: Instant.now().to_system_tz().add(days=-5).to_stdlib()
    )

Recursive Factory Dependencies#

You can use post_build to automatically create and associate related models if they aren’t provided.

from activemodel.pytest.factories import ActiveModelFactory
from app.models.order import TicketReservationOrder

class TicketReservationOrderFactory(ActiveModelFactory[TicketReservationOrder]):
    """
    Advanced factory that automatically handles recursive dependencies.
    """
    ticket_count = 1

    @classmethod
    def post_build(cls, model):
        """
        Automatically ensure the order has a Distribution and a Screening.
        """
        if not model.distribution_id:
            # Recursively use another factory
            from .distribution import DistributionFactory
            model.distribution = DistributionFactory.save()
            model.distribution_id = model.distribution.id

        if not model.screening_id:
            # Create a screening associated with the same distribution
            from .screening import ScreeningFactory
            model.screening = ScreeningFactory.save(
                distribution_id=model.distribution_id
            )
            model.screening_id = model.screening.id

        # Always save the model at the end of post_build in an ActiveModelFactory
        return model.save()

Complex Post-Save Logic#

Use post_save for actions that require the record to already have an ID or for complex side effects.

class FullyFundedScreeningFactory(ActiveModelFactory[Screening]):
    @classmethod
    def post_save(cls, model):
        """
        After creating a screening, automatically create enough
        paid orders to reach the funding goal.
        """
        from .order import TicketReservationOrderFactory

        ticket_count = 10
        for _ in range(ticket_count):
            TicketReservationOrderFactory.save(
                screening_id=model.id,
                status="paid"
            )

        # Refresh to ensure any computed fields/relationships are updated
        return model.refresh()

Standard Model Example#

A typical model combining common mixins:

from activemodel import BaseModel
from activemodel.mixins import (
    PydanticJSONMixin,
    SoftDeletionMixin,
    TimestampsMixin,
    TypeIDMixin,
)
from activemodel.types import TypeIDType
from sqlmodel import Field, Relationship

class Partner(
    BaseModel,
    TimestampsMixin,
    SoftDeletionMixin,
    TypeIDMixin("prt"),
    table=True,
):
    """
    Comprehensive model example with TypeID, Timestamps, and Soft Delete.
    """
    name: str = Field(nullable=False)
    slug: str = Field(nullable=False, unique=True)

    # Using foreign_key() helper for clean relationship definitions
    doctor_id: TypeIDType = Doctor.foreign_key()
    doctor: Doctor = Relationship()