Observed since 20 Oct, 18:36 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
SQLModel with developer ergonomics. Make SQLModel act like ActiveRecord.
No, this isn't really ActiveModel. It's just a wrapper around SQLModel that provides a ActiveRecord-like interface.
SQLModel is not an ORM. It's a SQL query builder and a schema definition tool. This drives me nuts because the developer ergonomics are terrible because of this.
This package provides a thin wrapper around SQLModel that provides a more ActiveRecord-like interface with things like:
save(), where(), find_or_create_by(), and upsert() for intuitive database operations.TypeIDPrimaryKey.whenever.Instant, whenever.PlainDateTime, and whenever.ZonedDateTime as first-class field annotations.created_at and updated_at tracking out of the box.before_save, after_create, and around_delete.deleted_at timestamp using the SoftDeletionMixin.[!TIP] This documentation is pretty bad. The tests and docstrs on code are the best way to learn how to use this.
uv add activemodel
First, setup your DB:
import activemodel
activemodel.init("sqlite:///database.db")
Create models:
from activemodel import BaseModel
from activemodel.mixins import TimestampsMixin
from activemodel.types.typeid import TypeIDPrimaryKey
from typeid import TypeID
class User(BaseModel, TimestampsMixin, table=True):
id: TypeID = TypeIDPrimaryKey("user")
a_field: str
You'll need to create the models in the DB. Alembic is the best way to do it, but you can cheat as well:
from sqlmodel import SQLModel
SQLModel.metadata.create_all(get_engine())
# now you can create a user! without managing sessions!
User(a_field="a").save()
Maybe you like JSON:
from sqlalchemy.dialects.postgresql import JSONB
from pydantic import BaseModel as PydanticBaseModel
from activemodel import BaseModel
from activemodel.mixins import PydanticJSONMixin, TimestampsMixin
from activemodel.types.typeid import TypeIDPrimaryKey
from typeid import TypeID
class SubObject(PydanticBaseModel):
name: str
value: int
class User(BaseModel, TimestampsMixin, PydanticJSONMixin, table=True):
id: TypeID = TypeIDPrimaryKey("user")
list_field: list[SubObject] = Field(sa_type=JSONB)
profile: SubObject = Field(sa_type=JSONB)
PydanticJSONMixin automatically rehydrates raw JSON from the database back into the annotated Pydantic types on load and refresh.
It also tracks in-place mutations automatically — no need to call flag_modified manually:
user = User.one("user_123")
# scalar field: mutating a nested attribute is detected automatically
user.profile.name = "new name"
user.save() # persists without flag_modified
# list field: all mutation methods trigger dirty tracking
user.list_field.append(SubObject(name="new", value=1))
user.list_field[0].name = "updated"
user.save() # persists
Supported field annotations:
SubModelSubModel | Nonelist[SubModel]list[SubModel] | NoneRaw dict, dict[...], list[dict], and top-level primitive list fields such as
list[str] and list[int] stay as plain Python containers on
load and refresh, but their in-place mutations are also tracked automatically.
Ambiguous unions like SubModel | dict | None are left as raw JSON since there is no unambiguous way to rehydrate them.
As with standard Pydantic, a raw dict will never compare equal to a model instance — use .model_dump() if you need dict comparison.
You'll probably want to query the model. Look ma, no sessions!
User.where(id="user_123").all()
# or, even better, for this case
User.one("user_123")
Magically creating sessions for DB operations is one of the main problems this project tackles. Even better, you can set a single session object to be used for all DB operations. This is helpful for DB transactions, specifically rolling back DB operations on each test.
BaseModel supports a small Rails-style lifecycle hook system.
The implemented hooks today are:
before_create, after_create, before_update, after_update, before_save, after_save, around_savebefore_delete, after_delete, around_deleteafter_find, after_initializeHook methods are optional. If a method with one of those names exists on the model, ActiveModel will call it at the appropriate time.
from contextlib import contextmanager
from activemodel import BaseModel
class User(BaseModel, table=True):
id: int | None = Field(default=None, primary_key=True)
email: str
def before_save(self):
self.email = self.email.strip().lower()
def after_find(self):
print(f"loaded user {self.id}")
def after_initialize(self):
print(f"initialized user {self.id}")
@contextmanager
def around_save(self):
print("before save")
yield
print("after save")
Some important semantics:
after_initialize runs on plain construction, so User(email="a@example.com") will trigger it even before the record is saved.after_find and then after_initialize.after_find is not called for plain construction.find_or_initialize_by() follows the Rails-style split: the existing-record path runs after_find then after_initialize, while the new-instance path only runs after_initialize.around_save and around_delete must be context managers.Current ordering is:
before_create -> before_save -> around_save -> after_create -> after_savebefore_update -> before_save -> around_save -> after_update -> after_savebefore_delete -> around_delete -> after_deleteafter_find -> after_initializeafter_initializeThere is one important scope limit to know about:
refresh() does not currently replay Rails-style read callbacks. It refreshes the object from the database, but it does not currently trigger after_find / after_initialize the way Rails reload effectively does.Also note that after_find / after_initialize only run for model instances. Lower-level query paths that return None, counts, scalars, or raw SQLAlchemy result objects are outside that contract.
Detailed instructions on how to integrate Alembic into your project can be found in the Alembic Integration documentation.
This tool is added to all BaseModels and makes it easy to write SQL queries. Some examples:
I hate the idea f
save(), it should save, not stick the save in a transaction.There are a couple of thorny problems we need to solve for here:
https://github.com/tomwojcik/starlette-context
Scrape.select().where(Scrape.id < last_scraped.id).all()MenuItem.select().where(MenuItem.menu_id == menu.id).all()IN example: CanonicalMenuItem.select().where(col(CanonicalMenuItem.id).in_(canonized_ids)).all()User.where((User.last_active_at != None) & (User.last_active_at > last_24_hours)).count()str(HostScreeningOrder.form_data["email"].as_string())Screening.where(Screening.theater_location['name'].astext.ilike('%AMC%'))SQLModel & SQLAlchemy are tricky. Here are some useful internal tricks:
__sqlmodel_relationships__ is where any RelationshipInfo objects are stored. This is used to generate relationship fields on the object.inspect(type(self)).relationships['distribution'] to inspect a specific generated relationship objectModelClass.relationship_name.property.local_columnsobject_state(instance).dict.get(field_name)attributes.set_committed_value(instance, field_name, val)instance_state(instance).modifiedselect(Table).outerjoin?? won't work in a ipython session, but Table.__table__.outerjoin?? will. __table__ is a reference to the underlying SQLAlchemy table record.get_engine().pool.stats() is helpful for inspecting connection pools and limits\whenever is a modern, type-safe datetime library for Python. Install the optional integration:
uv add activemodel[extras]
Once installed, you can use whenever.Instant, whenever.PlainDateTime, and whenever.ZonedDateTime directly as field type annotations — no sa_type= needed:
from whenever import Instant, PlainDateTime, ZonedDateTime
from activemodel import BaseModel
from activemodel.types.typeid import TypeIDPrimaryKey
from typeid import TypeID
class Event(BaseModel, table=True):
id: TypeID = TypeIDPrimaryKey("event")
triggered_at: Instant | None = None
local_time: PlainDateTime | None = None
scheduled_at: ZonedDateTime | None = None
PlainDateTime is stored as a naive TIMESTAMP / DATETIME value and round-trips as a local date-time without timezone information. On databases with native timezone-aware timestamp support, Instant and ZonedDateTime are stored as TIMESTAMP WITH TIME ZONE values. Instant round-trips exactly. ZonedDateTime preserves the UTC moment but not the original IANA timezone name (the DB stores the UTC offset at write time).
SQLite does not store timezone information in its datetime columns. If you use whenever fields with SQLite, make sure the environment writing and reading those values is configured with the server timezone semantics you expect. In practice, that means SQLite is best suited for local development or test scenarios where you control the process timezone behavior.
They also work in plain Pydantic response models without any extra setup, since whenever has built-in Pydantic v2 support:
from pydantic import BaseModel as PydanticBaseModel
from whenever import Instant
class EventResponse(PydanticBaseModel):
id: str
triggered_at: Instant
I'm a massive fan of Stripe-style prefixed UUIDs. There's an excellent project that defined a clear spec for these IDs. I've used the python implementation of this spec and developed a clean integration with SQLModel that plays well with fastapi as well.
Here's an example of defining a relationship:
import uuid
from activemodel import BaseModel
from activemodel.types import TypeIDType
from activemodel.types.typeid import TypeIDPrimaryKey
from sqlmodel import Field, Relationship
from typeid import TypeID
from .patient import Patient
class Appointment(BaseModel, table=True):
id: TypeID = TypeIDPrimaryKey("appointment")
# `foreign_key` is a activemodel method to generate the right `Field` for the relationship
# TypeIDType is really important here for fastapi serialization
doctor_id: TypeIDType = Doctor.foreign_key()
doctor: Doctor = Relationship()
Here's how to get the prefix associated with a given field:
model_class.__model__.model_fields["field_name"].sa_column.type.prefix
SQLModel does not currently support pydantic validations (when table=True). This is very surprising, but is actually the intended functionality:
For validation:
table=False and then inherit from that model in a model with table=True.Watch out for subtle differences across pydantic versions. There's some sneaky type inspection stuff in PydanticJSONMixin
that will break in subtle ways if the python, pydantic, etc versions don't match.
import pydantic
print(pydantic.VERSION)
import sys
print(sys.version)
This project was created from iloveitaly/python-package-template
Download counts and popularity will appear here as the collector receives them.
| Version | Published | Direct dependencies | Open version |
|---|---|---|---|
| 0.25.0 | 25 Sept 2026 | 3 |
This describes our collected data; it is not a security assessment.
Observed since 20 Oct, 18:36 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
package:bb1590fe89279ae667643097792bbb3bdb794fe2dda7abfa6d88182e1b831937