How to Make Auto-Updating Timestamp Fields in SQLModel

SQLModel Logo

It is very common in application and database design to keep fields on a table for when a record was created and last updated. These fields are common enough that many large frameworks like Laravel handle them automatically.

SQLModel is a relatively new Python library that combines the data validation of Pydantic with the ORM capabilities of SQLAlchemy. It is new enough, in fact, that there are no great resources for implementing auto-updating created_at and updated_at fields.

Created At Field

First, let’s start simple. Let’s make a created_at column that automatically sets its value to the current date and time when a record is inserted.

created_at: Optional[datetime] = Field(
        default_factory=lambda: datetime.now(timezone.utc)
    )

This is not too difficult to understand. Simply pass in a function handle that calls datetime.now(timezone.utc) when run to the default_factory parameter, and the created_at field will automatically be set. Note that timezone is imported from the datetime library. Now, let’s turn our attention to updated_at.

Updated At Field

updated_at needs all the functionality of created_at, plus its value needs to update whenever the record is updated.

updated_at: Optional[datetime] = Field(
        default_factory=lambda: datetime.now(timezone.utc),
        sa_column_kwargs={"onupdate": lambda: datetime.now(timezone.utc)},
    )

As you can see, updated_at is almost exactly identical to created_at, with one addition. We pass a dictionary called sa_column_args, with a value onupdate equal to a function that calls datetime.now(timezone.utc).

This is a little odd looking, no? Well, in order to access the onupdate parameter, we actually need to dig underneath the abstraction provided by the SQLModel library to get at some of the SQLAlchemy features that SQLModel does not have an interface for.

Under the hood, SQLModel creates an instance of a SQLAlchemy Column. sa_column_kwargs stands for “SQLAlchemy Column Keyword Arguments”, and it passes that dictionary to the constructor of the Column, allowing us to utilize the onupdate feature to set the value to the time the update occurs.

Conclusion

So, now you see how to create auto-updating datetime columns to track when record creations and updates occurred using SQLModel. Take note, the solution of using sa_column_kwargs to access the underlying SQLAlchemy implementation can be used all over SQLModel. Click here for another example. Hopefully this article helped you out, since when I needed to do this, I could find no resources explaining how it worked.

Note: This article was written when SQLModel was in a very early state (v. 0.0.16 at the time of writing). I certainly hope that in the future, a new and improved interface for accomplishing this very common task will be introduced.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

Ted Stresen-Reuter says:

THANK YOU for posting this. I wasn’t finding this documented anywhere and was pretty lost. Github copilot recommended sa_column_kwargs but I didn’t trust it really knew what it was doing (turns out it kind of did!)

Jacob Graham says:

You are very welcome. I had the exact same problem, and figured I could save someone a few hours of digging by posting it here. Things like this are bound to happen in a new library like SQLModel!

Hannus says:

Thanks. It is very useful.
SQLModel has updated to 0.0.19, it works well and it is the best practice now.

Jacob Graham says:

I am glad the post was useful to you!

Mark says:

In order for me to get this to working I had to change the `datetime.utcnow` to `lambda: datetime.now(tz=timezone.utc)` in Python 3.11 due to deprecation of `utcnow`.

Jacob Graham says:

A good point… thanks! A function that returns the current datetime does the job!

David says:

Thanks, very helpful.
I noticed the db does not store tzinfo, so using datetime.now() does the same.
I added a field_serializer to display the field name in the timezone set in my settings. My final Python 3.12 mixin looks like this:


class TimeStampable(SQLModel):
created_at: datetime = Field(default_factory=datetime.now)
modified_at: datetime = Field(
default_factory=datetime.now, sa_column_kwargs={"onupdate": datetime.now}
) # not exactly equal to created_at

@field_serializer("created_at", "modified_at", when_used="json")
def tz_aware_datefield(self, value: datetime) -> datetime:
return make_aware(value)

def make_aware(naive_dt: datetime, tz: ZoneInfo | None = None) -> datetime:
"""Make a naive datetime.datetime aware, assuming it is in UTC,
and convert to the provided timezone.
Timezone is defined by a setting, unless another time zone is specified.
"""
if naive_dt.tzinfo is not None:
raise ValueError(f"make_aware expects a naive datetime, got {naive_dt}")

utc_dt = naive_dt.replace(tzinfo=ZoneInfo("UTC"))
return utc_dt.astimezone(tz or get_settings().TIMEZONE)

Jacob Graham says:

Glad the post was helpful to you. And thanks for sharing your implementation!