Skip to content

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_at and updated_at are declared on Note itself with default_factory=utcnow, so both are set when an instance is constructed.
  • TimestampMixin for behavior. The mixin overrides save() to touch updated_at before delegating to Model.save(). It is a plain class — not a Model subclass — 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:

from datetime import UTC, datetime


def utcnow() -> datetime:
    return datetime.now(UTC)

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 timestamptimestamptz 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