Summary
Two related improvements surfaced while integrating sqlmodel-crud-utilities into a FastAPI/SQLModel microservice. Both concern mixin design for table=True models.
Issue 1 — AuditMixin uses Field(sa_column=Column(...)), which breaks when applied to more than one model
Problem
When a mixin defines a field as Field(sa_column=Column(...)), the same Column object is shared across every model class that inherits the mixin. SQLAlchemy raises on the second table:
sqlalchemy.exc.ArgumentError: Column object 'created_at' already assigned to Table 'location'
Root cause
Column(...) is evaluated once at class-body time and stored as the FieldInfo.sa_column value. SQLModel passes that exact object to every Table that inherits the mixin — but SQLAlchemy requires each Table to own its own Column instance.
Fix
Use declared_attr instead. SQLAlchemy calls the decorated method once per concrete model class, producing a fresh Column for each table:
from sqlalchemy import Column, DateTime, func
from sqlalchemy.orm import declared_attr
class TimestampMixin:
@declared_attr
def created_at(cls):
return Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
@declared_attr
def updated_at(cls):
return Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
Note: Do not add return-type annotations (-> Column) to the declared_attr methods — SQLAlchemy's Annotated Declarative scanner picks them up as bare field annotations and raises MappedAnnotationError (requires Mapped[]).
Issue 2 — AuditMixin uses default_factory (Python-side); bulk-upsert paths need a DB-side server_default
Problem
The current AuditMixin sets:
created_at: datetime = Field(default_factory=_utc_now, ...)
default_factory is evaluated by SQLModel/Pydantic when constructing a model instance in Python. Operations that bypass the ORM session — such as bulk_upsert_mappings — do not trigger Python-side defaults. For services that write rows in bulk, created_at will be NULL unless the caller explicitly sets it.
Fix
Use server_default=func.now() (or sa.text("now()")) so PostgreSQL sets the value at the database level, regardless of how the row was inserted:
@declared_attr
def created_at(cls):
return Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
Feature request — add a lightweight TimestampMixin
AuditMixin is the right tool when user tracking (created_by / updated_by) is needed. But many services have no auth context to populate those fields. A standalone TimestampMixin with only the two timestamp columns would cover the common case without schema noise:
# sqlmodel_crud_utils/mixins.py
from sqlalchemy import Column, DateTime, func
from sqlalchemy.orm import declared_attr
class TimestampMixin:
"""Adds created_at / updated_at audit columns to any table=True model.
Uses declared_attr so each concrete model class receives its own
fresh Column instance — safe to apply to multiple models.
Uses server_default=func.now() so timestamps are set by the database
even when rows are inserted via bulk operations that bypass the ORM.
"""
@declared_attr
def created_at(cls):
return Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
@declared_attr
def updated_at(cls):
return Column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
Usage:
class MyModel(TimestampMixin, SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
Environment
sqlmodel-crud-utilities v0.1.0 / v0.2.0
- SQLModel 0.0.21+
- SQLAlchemy 2.x
- Python 3.13
Summary
Two related improvements surfaced while integrating
sqlmodel-crud-utilitiesinto a FastAPI/SQLModel microservice. Both concern mixin design fortable=Truemodels.Issue 1 —
AuditMixinusesField(sa_column=Column(...)), which breaks when applied to more than one modelProblem
When a mixin defines a field as
Field(sa_column=Column(...)), the sameColumnobject is shared across every model class that inherits the mixin. SQLAlchemy raises on the second table:Root cause
Column(...)is evaluated once at class-body time and stored as theFieldInfo.sa_columnvalue. SQLModel passes that exact object to everyTablethat inherits the mixin — but SQLAlchemy requires eachTableto own its ownColumninstance.Fix
Use
declared_attrinstead. SQLAlchemy calls the decorated method once per concrete model class, producing a freshColumnfor each table:Issue 2 —
AuditMixinusesdefault_factory(Python-side); bulk-upsert paths need a DB-sideserver_defaultProblem
The current
AuditMixinsets:default_factoryis evaluated by SQLModel/Pydantic when constructing a model instance in Python. Operations that bypass the ORM session — such asbulk_upsert_mappings— do not trigger Python-side defaults. For services that write rows in bulk,created_atwill beNULLunless the caller explicitly sets it.Fix
Use
server_default=func.now()(orsa.text("now()")) so PostgreSQL sets the value at the database level, regardless of how the row was inserted:Feature request — add a lightweight
TimestampMixinAuditMixinis the right tool when user tracking (created_by/updated_by) is needed. But many services have no auth context to populate those fields. A standaloneTimestampMixinwith only the two timestamp columns would cover the common case without schema noise:Usage:
Environment
sqlmodel-crud-utilitiesv0.1.0 / v0.2.0