Skip to content

Commit cc998aa

Browse files
authored
✨ Use UTC datetimes by default (#2099)
1 parent ff86856 commit cc998aa

25 files changed

Lines changed: 964 additions & 8 deletions

‎docs/advanced/datetime.md‎

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# Datetimes and Timezones
2+
3+
You can use Python's `datetime` type to store dates and times, for example when a record was created.
4+
5+
A datetime can also contain **timezone information**:
6+
7+
* An **aware datetime** has timezone information. For example, `datetime.now(UTC)` creates an aware datetime in **UTC**.
8+
* A **naive datetime** has no timezone information. On its own, it doesn't tell us which timezone the time belongs to.
9+
10+
/// note
11+
12+
The default UTC datetime behavior described here is available since SQLModel version `0.0.45`.
13+
14+
In version `0.0.44` and earlier, `datetime` fields used `DateTime(timezone=False)` by default. See [Upgrade Existing Applications](#upgrade-existing-applications) when upgrading.
15+
16+
///
17+
18+
## Models with Datetimes
19+
20+
Let's say that each event in our application has a `created_at` field.
21+
22+
We can declare it with `datetime` and use `default_factory` to generate the current time in UTC:
23+
24+
{* ./docs_src/advanced/datetime/tutorial001_py311.py ln[1:8] hl[1,8] *}
25+
26+
/// tip
27+
28+
The [`UTC` constant](https://docs.python.org/3/library/datetime.html#datetime.UTC) is available on Python 3.11 and newer. It is an alias for `timezone.utc`.
29+
30+
For Python 3.10, expand **Other versions and variants** below the example. That version imports `timezone` and uses `datetime.now(timezone.utc)` instead.
31+
32+
///
33+
34+
Here, `lambda` creates a small **function without a name**. It takes no arguments and returns `datetime.now(UTC)` when called.
35+
36+
We pass this function to `default_factory` so SQLModel can call it each time we create an `Event` without providing `created_at`. This way, each event gets its own creation time.
37+
38+
Passing `datetime.now(UTC)` directly would call it immediately, when defining the model class. `default_factory` needs a **function** to call later, not a datetime value.
39+
40+
We can create an event without passing a value for `created_at`:
41+
42+
{* ./docs_src/advanced/datetime/tutorial001_py311.py ln[17:19] hl[18] *}
43+
44+
For the database column, **SQLModel** uses `UTCDateTime`, a custom SQLAlchemy type based on `DateTime(timezone=True)`. It uses native timezone support when the database provides it.
45+
46+
### Write and Read Datetimes
47+
48+
Let's save the event to `database.db` and read it back using SQLite:
49+
50+
{* ./docs_src/advanced/datetime/tutorial001_py311.py ln[11:14,21:26] hl[23:25] *}
51+
52+
The datetime goes through these steps:
53+
54+
1. When we create the event, the default factory returns an **aware UTC datetime**.
55+
2. When SQLModel sends the value to the database, `UTCDateTime` converts it to UTC. If it is already in UTC, the time stays the same.
56+
3. When SQLModel reads the value back, `UTCDateTime` returns an **aware UTC datetime**, including when the database stores datetimes without timezone information.
57+
58+
For example, a value of `14:00+02:00` would come back as `12:00+00:00`. Both represent the same instant. The original timezone name or offset is not preserved.
59+
60+
This processing happens when executing database statements and reading their results. It doesn't change values when constructing a model or assigning attributes. The original value on an existing instance stays unchanged until it is refreshed or reloaded.
61+
62+
## Aware and Naive Datetimes
63+
64+
Pydantic provides [`AwareDatetime` and `NaiveDatetime`](https://docs.pydantic.dev/latest/api/standard_library_types/#datetimes) to validate whether a datetime has timezone information.
65+
66+
We can use them as field types:
67+
68+
{* ./docs_src/advanced/datetime/tutorial002_py310.py ln[1:8] hl[1,7:8] *}
69+
70+
Here, `starts_at` requires timezone information during validation. It uses the same database type as `datetime`: `UTCDateTime`.
71+
72+
The field `local_time` must have **no timezone information**. It uses `DateTime(timezone=False)`, for values that intentionally represent a local time without a timezone.
73+
74+
These types also work with `| None` and with `Annotated`, for example `Annotated[datetime, NaiveDatetime]`.
75+
76+
### Validate the Data
77+
78+
We can use `Event.model_validate()` to validate the data before creating an event:
79+
80+
{* ./docs_src/advanced/datetime/tutorial002_py310.py ln[11:17] hl[13:14,16] *}
81+
82+
The `Z` at the end of `starts_at` means **UTC**. The value for `local_time` has no timezone, so both values pass validation.
83+
84+
If we pass a naive value for `starts_at`, or an aware value for `local_time`, Pydantic raises a validation error.
85+
86+
/// note
87+
88+
For table models, constructing an instance directly with `Event(...)` currently does not enforce these Pydantic constraints. This will change in a future version of SQLModel. For now, use `Event.model_validate(data)` when you need validation.
89+
90+
Loading an instance from the database does not enforce these Pydantic constraints either.
91+
92+
///
93+
94+
Plain `datetime` still accepts both aware and naive values during **Pydantic validation**. But `UTCDateTime` rejects naive database parameters. It doesn't assume that a naive input means UTC or the computer's local timezone.
95+
96+
For example, using `datetime.now()` without a timezone would fail when the session flushes, including during `session.commit()`. SQLAlchemy raises a `StatementError` wrapping a `ValueError` that explains how to supply an aware datetime or use `NaiveDatetime`.
97+
98+
The same rule applies to datetime parameters in updates and queries, such as comparing a field to a datetime. Nullable fields still accept `None`.
99+
100+
Explicit `Field(sa_type=...)` and `Field(sa_column=...)` declarations continue to take precedence over the inferred database type. They bypass SQLModel's UTC processing unless they explicitly use `UTCDateTime`, which you can import from `sqlmodel`.
101+
102+
## Database Support
103+
104+
The underlying storage depends on the [SQLAlchemy dialect and database type](https://docs.sqlalchemy.org/en/20/core/type_basics.html#sqlalchemy.types.DateTime). `UTCDateTime` adds UTC processing around that storage:
105+
106+
* **PostgreSQL** uses `TIMESTAMP WITH TIME ZONE`. It stores an instant in time and returns it using the session timezone, without retaining the original timezone name or offset. `UTCDateTime` converts returned values to UTC.
107+
* **SQLite** stores values without timezone information when using SQLAlchemy's default datetime format. `UTCDateTime` first converts the input to UTC, then restores UTC on the naive value read from the database.
108+
* **MySQL and MariaDB** use `DATETIME`, which ignores the `timezone` flag. `UTCDateTime` supplies UTC values and restores UTC on naive results. It does not switch the column to MySQL's `TIMESTAMP` type.
109+
110+
You can read more in the [PostgreSQL datetime documentation](https://www.postgresql.org/docs/current/datatype-datetime.html), [SQLAlchemy SQLite documentation](https://docs.sqlalchemy.org/en/20/dialects/sqlite.html#sqlalchemy.dialects.sqlite.DATETIME), and [SQLAlchemy MySQL documentation](https://docs.sqlalchemy.org/en/20/dialects/mysql.html#sqlalchemy.dialects.mysql.DATETIME).
111+
112+
### Use UTC Datetimes
113+
114+
For timestamps that represent an instant in time, it is recommended using **UTC datetimes in your application code**, whichever database you use. Create them with `datetime.now(UTC)`, as in the [example above](#models-with-datetimes).
115+
116+
Raw SQL, other applications, and defaults generated by the database bypass the custom type's processing of input values. Make sure they also write UTC to columns that store datetimes without timezone information. For example, MySQL's `CURRENT_TIMESTAMP` uses the connection's timezone, so a connection generating these values needs to use UTC.
117+
118+
## Upgrade Existing Applications
119+
120+
/// warning
121+
122+
Changing the default from `DateTime(timezone=False)` to `UTCDateTime` is a **breaking change**. The new default requires aware database parameters and returns aware UTC values when reading.
123+
124+
Upgrading SQLModel changes your model metadata. It does **not** alter existing database columns or convert existing data. `SQLModel.metadata.create_all()` does not update existing columns either.
125+
126+
///
127+
128+
Review each datetime field and choose whether to keep naive storage or adopt UTC storage. Existing explicit `sa_type` or `sa_column` overrides retain their behavior, including an explicit `DateTime(timezone=True)`.
129+
130+
If you adopt UTC storage, keep `datetime` or use `AwareDatetime` to require aware input during validation. Follow the [UTC recommendation above](#use-utc-datetimes) for new timestamps. Update comparisons and query parameters to use aware values, and check API responses for the added UTC timezone information.
131+
132+
Before adopting UTC storage, determine what timezone existing naive values represent. This information cannot be recovered from the column type. Reading an old local time as UTC would change its meaning. Resolve ambiguous or nonexistent times around daylight-saving transitions and mixed or unknown timezones before converting data.
133+
134+
Back up the database and test schema changes, data conversions, and application behavior against a copy, using the same driver as production. Coordinate the migration with all writers so that old and new applications don't mix timezone conventions.
135+
136+
### Keep Naive Storage
137+
138+
Change the field annotation to `NaiveDatetime`:
139+
140+
{* ./docs_src/advanced/datetime/tutorial005_py310.py ln[1:9] hl[3,9] *}
141+
142+
This keeps the previous database column type, so no database migration is needed for that field. It also adds the validation constraint described above.
143+
144+
To retain the previous database type **and** plain `datetime` validation behavior, declare the database type explicitly:
145+
146+
{* ./docs_src/advanced/datetime/tutorial003_py310.py ln[1:9] hl[3,9] *}
147+
148+
### Adopt Timezone-Aware Storage on PostgreSQL
149+
150+
1. Create an Alembic migration with your models imported in `env.py` and `target_metadata=SQLModel.metadata`. Run `alembic revision --autogenerate -m "Use timezone-aware datetimes"`.
151+
2. Review the generated migration. For each affected column, add an explicit conversion using the timezone of the existing values. Autogeneration cannot determine this timezone for you.
152+
3. After testing, apply the migration with `alembic upgrade head` as part of the application deployment.
153+
154+
For example, if `event.created_at` contains naive **UTC** values, the operations in the generated revision can be:
155+
156+
{* ./docs_src/advanced/datetime/tutorial004_py310.py hl[9:11,19:21] *}
157+
158+
This migration uses `sa.DateTime(timezone=True)`, the underlying database type of `UTCDateTime`.
159+
160+
By default, Alembic renders this type as `sqlmodel.sql.sqltypes.UTCDateTime()`. Add `import sqlmodel.sql.sqltypes` at the top of the migration to use the generated code.
161+
162+
If the existing values represent another timezone, replace `UTC` in both `upgrade()` and `downgrade()` with that timezone. The downgrade converts instants back to naive values in the original timezone.
163+
164+
Without an explicit conversion, PostgreSQL interprets the old values using the database session's `TimeZone`, which can change the intended instants. Review server defaults and dependent indexes or constraints as part of the migration, too.
165+
166+
See [Alembic autogeneration](https://alembic.sqlalchemy.org/en/latest/autogenerate.html) and [`alter_column()`'s `postgresql_using` option](https://alembic.sqlalchemy.org/en/latest/ops.html#alembic.operations.Operations.alter_column).
167+
168+
### Adopt UTC Storage on MySQL, MariaDB, and SQLite
169+
170+
These databases keep the same `DATETIME` column definition, so adopting `UTCDateTime` does **not** require a schema change by itself.
171+
172+
If existing values are already naive **UTC**, no data conversion is needed.
173+
174+
If they represent another timezone, create a **data migration** that converts them to UTC before the new application reads them.
175+
176+
Alembic's schema autogeneration cannot detect or generate this data conversion. An empty autogenerated migration does not mean the existing timestamps are ready for the new type.
177+
178+
### Other Databases
179+
180+
For other databases, inspect the generated column type and test writes and reads with the driver you use. Follow that database's schema and data migration procedures. Native timezone support and accepted input values vary by dialect and driver. Some require an explicit dialect-specific timestamp type.

‎docs/release-notes.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
## Latest Changes
44

5+
### Breaking Changes
6+
7+
`datetime` fields now use `UTCDateTime`, a custom type based on `DateTime(timezone=True)`. It requires aware datetime parameters, normalizes them to UTC, and returns aware UTC values from database reads, including on SQLite and MySQL/MariaDB. Pydantic's `AwareDatetime` uses the same type, while `NaiveDatetime` explicitly selects `DateTime(timezone=False)`. Plain `datetime` validation still accepts both aware and naive values, but naive database parameters now raise an error.
8+
9+
Existing databases are not changed automatically. To retain naive storage, use `NaiveDatetime` or an explicit `Field(sa_type=DateTime(timezone=False))`. Explicit SQLAlchemy types retain their existing behavior. PostgreSQL needs a column migration that explicitly specifies the timezone of existing values. SQLite and MySQL/MariaDB keep their column definitions, but existing non-UTC values need a data migration before adopting the new type. Update datetime producers, query parameters, comparisons, and any database defaults to follow the new UTC convention.
10+
11+
Read [Datetimes and Timezones: Upgrade Existing Applications](https://sqlmodel.tiangolo.com/advanced/datetime/#upgrade-existing-applications) before upgrading.
12+
513
## 0.0.44 (2026-09-21)
614

715
### Refactors

‎docs_src/advanced/datetime/__init__.py‎

Whitespace-only changes.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from datetime import datetime, timezone
2+
3+
from sqlmodel import Field, Session, SQLModel, create_engine
4+
5+
6+
class Event(SQLModel, table=True):
7+
id: int | None = Field(default=None, primary_key=True)
8+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
9+
10+
11+
sqlite_file_name = "database.db"
12+
sqlite_url = f"sqlite:///{sqlite_file_name}"
13+
14+
engine = create_engine(sqlite_url, echo=True)
15+
16+
17+
def main():
18+
event = Event()
19+
print("Event:", event)
20+
21+
SQLModel.metadata.create_all(engine)
22+
with Session(engine) as session:
23+
session.add(event)
24+
session.commit()
25+
session.refresh(event)
26+
print("Event from database:", event)
27+
28+
29+
if __name__ == "__main__":
30+
main()
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from datetime import UTC, datetime
2+
3+
from sqlmodel import Field, Session, SQLModel, create_engine
4+
5+
6+
class Event(SQLModel, table=True):
7+
id: int | None = Field(default=None, primary_key=True)
8+
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
9+
10+
11+
sqlite_file_name = "database.db"
12+
sqlite_url = f"sqlite:///{sqlite_file_name}"
13+
14+
engine = create_engine(sqlite_url, echo=True)
15+
16+
17+
def main():
18+
event = Event()
19+
print("Event:", event)
20+
21+
SQLModel.metadata.create_all(engine)
22+
with Session(engine) as session:
23+
session.add(event)
24+
session.commit()
25+
session.refresh(event)
26+
print("Event from database:", event)
27+
28+
29+
if __name__ == "__main__":
30+
main()
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from pydantic import AwareDatetime, NaiveDatetime
2+
from sqlmodel import Field, SQLModel
3+
4+
5+
class Event(SQLModel, table=True):
6+
id: int | None = Field(default=None, primary_key=True)
7+
starts_at: AwareDatetime
8+
local_time: NaiveDatetime
9+
10+
11+
def main():
12+
data = {
13+
"starts_at": "2026-01-01T12:00:00Z",
14+
"local_time": "2026-01-01T12:00:00",
15+
}
16+
event = Event.model_validate(data)
17+
print("Event:", event)
18+
19+
20+
if __name__ == "__main__":
21+
main()
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from datetime import datetime
2+
3+
from sqlalchemy import DateTime
4+
from sqlmodel import Field, SQLModel
5+
6+
7+
class Event(SQLModel, table=True):
8+
id: int | None = Field(default=None, primary_key=True)
9+
created_at: datetime = Field(sa_type=DateTime(timezone=False))
10+
11+
12+
def main():
13+
event = Event(created_at=datetime(2026, 1, 1, 12))
14+
print("Event:", event)
15+
16+
17+
if __name__ == "__main__":
18+
main()
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import sqlalchemy as sa
2+
from alembic import op
3+
4+
5+
def upgrade():
6+
op.alter_column(
7+
"event",
8+
"created_at",
9+
existing_type=sa.DateTime(timezone=False),
10+
type_=sa.DateTime(timezone=True),
11+
postgresql_using="created_at AT TIME ZONE 'UTC'",
12+
)
13+
14+
15+
def downgrade():
16+
op.alter_column(
17+
"event",
18+
"created_at",
19+
existing_type=sa.DateTime(timezone=True),
20+
type_=sa.DateTime(timezone=False),
21+
postgresql_using="created_at AT TIME ZONE 'UTC'",
22+
)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from datetime import datetime
2+
3+
from pydantic import NaiveDatetime
4+
from sqlmodel import Field, SQLModel
5+
6+
7+
class Event(SQLModel, table=True):
8+
id: int | None = Field(default=None, primary_key=True)
9+
created_at: NaiveDatetime
10+
11+
12+
def main():
13+
event = Event.model_validate({"created_at": datetime(2026, 1, 1, 12)})
14+
print("Event:", event)
15+
16+
17+
if __name__ == "__main__":
18+
main()

‎mkdocs.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ nav:
116116
- tutorial/fastapi/tests.md
117117
- "":
118118
- advanced/index.md
119+
- advanced/datetime.md
119120
- advanced/decimal.md
120121
- advanced/uuid.md
121122
- "":

0 commit comments

Comments
 (0)