Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions docs/advanced/datetime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# Datetimes and Timezones

You can use Python's `datetime` type to store dates and times, for example when a record was created.

A datetime can also contain **timezone information**:

* An **aware datetime** has timezone information. For example, `datetime.now(UTC)` creates an aware datetime in **UTC**.
* A **naive datetime** has no timezone information. On its own, it doesn't tell us which timezone the time belongs to.

/// note

The default UTC datetime behavior described here is available since SQLModel version `0.0.45`.

In version `0.0.44` and earlier, `datetime` fields used `DateTime(timezone=False)` by default. See [Upgrade Existing Applications](#upgrade-existing-applications) when upgrading.

///

## Models with Datetimes

Let's say that each event in our application has a `created_at` field.

We can declare it with `datetime` and use `default_factory` to generate the current time in UTC:

{* ./docs_src/advanced/datetime/tutorial001_py311.py ln[1:8] hl[1,8] *}

/// tip

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`.

For Python 3.10, expand **Other versions and variants** below the example. That version imports `timezone` and uses `datetime.now(timezone.utc)` instead.

///

Here, `lambda` creates a small **function without a name**. It takes no arguments and returns `datetime.now(UTC)` when called.

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.

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.

We can create an event without passing a value for `created_at`:

{* ./docs_src/advanced/datetime/tutorial001_py311.py ln[17:19] hl[18] *}

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.

### Write and Read Datetimes

Let's save the event to `database.db` and read it back using SQLite:

{* ./docs_src/advanced/datetime/tutorial001_py311.py ln[11:14,21:26] hl[23:25] *}

The datetime goes through these steps:

1. When we create the event, the default factory returns an **aware UTC datetime**.
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.
3. When SQLModel reads the value back, `UTCDateTime` returns an **aware UTC datetime**, including when the database stores datetimes without timezone information.

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.

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.

## Aware and Naive Datetimes

Pydantic provides [`AwareDatetime` and `NaiveDatetime`](https://docs.pydantic.dev/latest/api/standard_library_types/#datetimes) to validate whether a datetime has timezone information.

We can use them as field types:

{* ./docs_src/advanced/datetime/tutorial002_py310.py ln[1:8] hl[1,7:8] *}

Here, `starts_at` requires timezone information during validation. It uses the same database type as `datetime`: `UTCDateTime`.

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.

These types also work with `| None` and with `Annotated`, for example `Annotated[datetime, NaiveDatetime]`.

### Validate the Data

We can use `Event.model_validate()` to validate the data before creating an event:

{* ./docs_src/advanced/datetime/tutorial002_py310.py ln[11:17] hl[13:14,16] *}

The `Z` at the end of `starts_at` means **UTC**. The value for `local_time` has no timezone, so both values pass validation.

If we pass a naive value for `starts_at`, or an aware value for `local_time`, Pydantic raises a validation error.

/// note

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.

Loading an instance from the database does not enforce these Pydantic constraints either.

///

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.

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`.

The same rule applies to datetime parameters in updates and queries, such as comparing a field to a datetime. Nullable fields still accept `None`.

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`.

## Database Support

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:

* **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.
* **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.
* **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.

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).

### Use UTC Datetimes

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).

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.

## Upgrade Existing Applications

/// warning

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.

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.

///

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)`.

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.

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.

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.

### Keep Naive Storage

Change the field annotation to `NaiveDatetime`:

{* ./docs_src/advanced/datetime/tutorial005_py310.py ln[1:9] hl[3,9] *}

This keeps the previous database column type, so no database migration is needed for that field. It also adds the validation constraint described above.

To retain the previous database type **and** plain `datetime` validation behavior, declare the database type explicitly:

{* ./docs_src/advanced/datetime/tutorial003_py310.py ln[1:9] hl[3,9] *}

### Adopt Timezone-Aware Storage on PostgreSQL

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"`.
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.
3. After testing, apply the migration with `alembic upgrade head` as part of the application deployment.

For example, if `event.created_at` contains naive **UTC** values, the operations in the generated revision can be:

{* ./docs_src/advanced/datetime/tutorial004_py310.py hl[9:11,19:21] *}

This migration uses `sa.DateTime(timezone=True)`, the underlying database type of `UTCDateTime`.

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.

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.

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.

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).

### Adopt UTC Storage on MySQL, MariaDB, and SQLite

These databases keep the same `DATETIME` column definition, so adopting `UTCDateTime` does **not** require a schema change by itself.

If existing values are already naive **UTC**, no data conversion is needed.

If they represent another timezone, create a **data migration** that converts them to UTC before the new application reads them.

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.

### Other Databases

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.
8 changes: 8 additions & 0 deletions docs/release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## Latest Changes

### Breaking Changes

`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.

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.

Read [Datetimes and Timezones: Upgrade Existing Applications](https://sqlmodel.tiangolo.com/advanced/datetime/#upgrade-existing-applications) before upgrading.

## 0.0.44 (2026-09-21)

### Refactors
Expand Down
Empty file.
30 changes: 30 additions & 0 deletions docs_src/advanced/datetime/tutorial001_py310.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from datetime import datetime, timezone

from sqlmodel import Field, Session, SQLModel, create_engine


class Event(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))


sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"

engine = create_engine(sqlite_url, echo=True)


def main():
event = Event()
print("Event:", event)

SQLModel.metadata.create_all(engine)
with Session(engine) as session:
session.add(event)
session.commit()
session.refresh(event)
print("Event from database:", event)


if __name__ == "__main__":
main()
30 changes: 30 additions & 0 deletions docs_src/advanced/datetime/tutorial001_py311.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from datetime import UTC, datetime

from sqlmodel import Field, Session, SQLModel, create_engine


class Event(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))


sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"

engine = create_engine(sqlite_url, echo=True)


def main():
event = Event()
print("Event:", event)

SQLModel.metadata.create_all(engine)
with Session(engine) as session:
session.add(event)
session.commit()
session.refresh(event)
print("Event from database:", event)


if __name__ == "__main__":
main()
21 changes: 21 additions & 0 deletions docs_src/advanced/datetime/tutorial002_py310.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from pydantic import AwareDatetime, NaiveDatetime
from sqlmodel import Field, SQLModel


class Event(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
starts_at: AwareDatetime
local_time: NaiveDatetime


def main():
data = {
"starts_at": "2026-01-01T12:00:00Z",
"local_time": "2026-01-01T12:00:00",
}
event = Event.model_validate(data)
print("Event:", event)


if __name__ == "__main__":
main()
18 changes: 18 additions & 0 deletions docs_src/advanced/datetime/tutorial003_py310.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from datetime import datetime

from sqlalchemy import DateTime
from sqlmodel import Field, SQLModel


class Event(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
created_at: datetime = Field(sa_type=DateTime(timezone=False))


def main():
event = Event(created_at=datetime(2026, 1, 1, 12))
print("Event:", event)


if __name__ == "__main__":
main()
22 changes: 22 additions & 0 deletions docs_src/advanced/datetime/tutorial004_py310.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import sqlalchemy as sa
from alembic import op


def upgrade():
op.alter_column(
"event",
"created_at",
existing_type=sa.DateTime(timezone=False),
type_=sa.DateTime(timezone=True),
postgresql_using="created_at AT TIME ZONE 'UTC'",
)


def downgrade():
op.alter_column(
"event",
"created_at",
existing_type=sa.DateTime(timezone=True),
type_=sa.DateTime(timezone=False),
postgresql_using="created_at AT TIME ZONE 'UTC'",
)
18 changes: 18 additions & 0 deletions docs_src/advanced/datetime/tutorial005_py310.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from datetime import datetime

from pydantic import NaiveDatetime
from sqlmodel import Field, SQLModel


class Event(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
created_at: NaiveDatetime


def main():
event = Event.model_validate({"created_at": datetime(2026, 1, 1, 12)})
print("Event:", event)


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ nav:
- tutorial/fastapi/tests.md
- "":
- advanced/index.md
- advanced/datetime.md
- advanced/decimal.md
- advanced/uuid.md
- "":
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ github-actions = [
"smokeshow >=0.5.0",
]
tests = [
"alembic >=1.12.0",
"black >=24.1.0",
"coverage[toml] >=6.2",
"dirty-equals >=0.11",
Expand Down
27 changes: 27 additions & 0 deletions sqlmodel/.agents/skills/sqlmodel/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,33 @@ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)

Use non-table `SQLModel` classes for create/update/public API schemas instead of mixing request/response-only fields into table models.

## Datetimes

In SQLModel `0.0.45` and newer, use `datetime` for timestamp fields. SQLModel automatically selects `UTCDateTime`, which normalizes aware values to UTC when writing and returns aware UTC datetimes when reading, including with SQLite and MySQL/MariaDB.

Do not add `sa_type=DateTime(timezone=True)` or `sa_column=Column(DateTime(timezone=True))` just to enable timezone support. These overrides bypass SQLModel's UTC processing. If an explicit column type is needed, import `UTCDateTime` from `sqlmodel`.

For generated timestamps on Python 3.11 and newer:

```python
from datetime import UTC, datetime

from sqlmodel import Field, SQLModel


class Event(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
```

On Python 3.10, import `timezone` instead of `UTC` and use `lambda: datetime.now(timezone.utc)`.

- Supply aware datetimes for writes, updates, and query filters. Avoid `datetime.now()` without a timezone and `datetime.utcnow()` for UTC timestamps. Naive database parameters are rejected when the statement executes.
- Use Pydantic's `AwareDatetime` when input validation must reject naive values. It uses the same UTC storage. Validate table-model input with `Model.model_validate(data)`. Direct table-model construction currently skips Pydantic validation, and plain `datetime` validation accepts naive values.
- Use Pydantic's `NaiveDatetime` for intentional naive storage. It selects `DateTime(timezone=False)` and requires naive values during Pydantic validation.

When upgrading existing applications, follow the [datetime migration guide](https://sqlmodel.tiangolo.com/advanced/datetime/#upgrade-existing-applications). Changing the model does not alter existing columns or convert historical data.

## Sessions and Queries

Open sessions directly with the engine:
Expand Down
1 change: 1 addition & 0 deletions sqlmodel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,4 @@
from .sql.expression import type_coerce as type_coerce
from .sql.expression import within_group as within_group
from .sql.sqltypes import AutoString as AutoString
from .sql.sqltypes import UTCDateTime as UTCDateTime
Loading
Loading