Timestamps¶
Track when rows are created and last modified with created_at / updated_at fields: created_at is filled by a field default, and updated_at is refreshed by a small mixin that hooks save().
The Pattern¶
from datetime import UTC, datetime
from ferro import Field, Model, connect, engines
def utcnow() -> datetime:
return datetime.now(UTC)
class TimestampMixin:
"""Touch ``updated_at`` on every save.
A plain mixin (not a Model subclass): declare the timestamp fields on
each concrete model, and the mixin keeps them fresh.
"""
async def save(self, **kwargs) -> None:
self.updated_at = utcnow()
await super().save(**kwargs)
class Note(TimestampMixin, Model):
id: int | None = Field(default=None, primary_key=True)
text: str
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)
from datetime import UTC, datetime
from typing import Annotated
from ferro import Field, Model, connect, engines
def utcnow() -> datetime:
return datetime.now(UTC)
class TimestampMixin:
"""Touch ``updated_at`` on every save.
A plain mixin (not a Model subclass): declare the timestamp fields on
each concrete model, and the mixin keeps them fresh.
"""
async def save(self, **kwargs) -> None:
self.updated_at = utcnow()
await super().save(**kwargs)
class Note(TimestampMixin, Model):
id: Annotated[int | None, Field(default=None, primary_key=True)]
text: str
created_at: Annotated[datetime, Field(default_factory=utcnow)]
updated_at: Annotated[datetime, Field(default_factory=utcnow)]
Two pieces work together:
- Field defaults on the concrete model.
created_atandupdated_atare declared onNoteitself withdefault_factory=utcnow, so both are set when an instance is constructed. TimestampMixinfor behavior. The mixin overridessave()to touchupdated_atbefore delegating toModel.save(). It is a plain class — not aModelsubclass — and contributes only behavior, never fields.
Every model that wants timestamps repeats the same two field declarations and adds the mixin to its bases (mixin first, so its save() wins in the MRO).
Why a mixin instead of a Model base class?
You might expect to declare the fields once on a shared base, e.g. class Timestamped(Model) with created_at / updated_at, and inherit from it. Ferro does not support this: the ORM registers a table schema for each model class as it is defined, so fields declared on a Model base class are not contributed to its subclasses' tables. Keep shared behavior in a plain mixin and declare fields on each concrete model.
Usage¶
note = await Note.create(text="first draft")
original = note.updated_at
note.text = "second draft"
await note.save()
assert note.updated_at > original
assert note.created_at <= note.updated_at
created_at is set once when the instance is created; every subsequent save() advances updated_at. Note that the mixin only hooks instance save() (which create() paths go through) — batch updates like Note.where(...).update(...) write columns directly and will not touch updated_at unless you set it explicitly in the update.
Timezone Notes¶
Store UTC, always. The example's utcnow() helper returns a timezone-aware datetime:
Avoid naive datetime.now() — it captures the server's local clock, which makes values ambiguous and breaks comparisons across hosts and DST changes. Keep storage in UTC and convert to the user's timezone only at the display layer.
Naive vs timezone-aware columns¶
A datetime field maps to Postgres timestamptz (SQLite stores both the same
way). If you point a model at a pre-existing plain timestamp (no time zone)
column, Ferro will not silently convert it — auto-migrate warns and leaves the
column untouched, because a timestamp → timestamptz cast reinterprets stored
values under the connection's timezone and can shift your data.
- To keep the column naive, declare the field with
db_type="timestamp". - To convert it intentionally, run a reviewed migration (Alembic) with an explicit
source timezone, e.g.
USING occurred_at AT TIME ZONE 'UTC'.
See Also¶
- Models & Fields guide — field defaults and
default_factory - Soft Deletes how-to — the same mixin pattern applied to deletion
- Mutations guide — instance
save()vs batchupdate()