DateTime Behavior

There must be a way to handle datetime data, such that we can keep straight any timezone(s) which may be involved for the app.

As a rule, we store datetime values as “naive/UTC” within the app database, and convert to “aware/local” as needed for display to the user etc.

A related rule is that any naive datetime is assumed to be UTC. If you have a naive/local value then you should convert it to aware/local or else the framework logic will misinterpret it. (See below, Convert to Local Time.)

With these rules in place, the workhorse methods are:

Time Zone Config/Lookup

Technically no config is required; the default timezone can be gleaned from the OS:

# should always return *something* :)
tz_default = app.get_timezone()

The default (aka. local) timezone is used by localtime() and therefore also render_datetime().

Config can override the default/local timezone; it’s assumed most apps will do this. If desired, other alternate timezone(s) may be configured as well:

[wutta]
timezone.default = America/Chicago
timezone.eastcoast = America/New_York
timezone.westcoast = America/Los_Angeles

Corresponding datetime.tzinfo objects can be fetched via get_timezone():

tz_default = app.get_timezone() # => America/Chicago

tz_eastcoast = app.get_timezone("eastcoast")
tz_westcoast = app.get_timezone("westcoast")

UTC vs. Local Time

Since we store values as naive/UTC, but display to the user as aware/local, we often need to convert values between these (and related) formats.

Convert to UTC

When a datetime value is written to the app DB, it must be naive/UTC.

Below are 4 examples for converting values to UTC time zone. In short, use make_utc() - but if providing a naive value, it must already be UTC! (Because all naive values are assumed to be UTC. Provide zone-aware values when necessary to avoid confusion.)

These examples assume America/Chicago (UTC-0600) for the “local” timezone, with a local time value of 2:15 PM (so, 8:15 PM UTC):

# naive/UTC => naive/UTC
# (nb. this conversion is not actually needed of course, but we
# show the example to be thorough)
# nb. value has no timezone but is already correct (8:15 PM UTC)
dt = datetime.datetime(2025, 12, 16, 20, 15)
utc = app.make_utc(dt)

# aware/UTC => naive/UTC
# nb. value has expicit timezone, w/ correct time (8:15 PM UTC)
dt = datetime.datetime(2025, 12, 16, 20, 15, tzinfo=datetime.timezone.utc)
utc = app.make_utc(dt)

# aware/local => naive/UTC
tzlocal = app.get_timezone()
# nb. value has expicit timezone, w/ correct time (2:15 PM local)
dt = datetime.datetime(2025, 12, 16, 14, 15, tzinfo=tzlocal)
utc = app.make_utc(dt)

If your value is naive/local then you can’t simply pass it to make_utc() - since that assumes naive values are already UTC. (Again, all naive values are assumed to be UTC.)

Instead, first call localtime() with from_utc=False to add local time zone awareness:

# naive/local => naive/UTC
# nb. value has no timezone but is correct for local zone (2:15 PM)
dt = datetime.datetime(2025, 12, 16, 14, 15)
# must first convert, and be sure to specify it's *not* UTC
# (in practice this just sets the local timezone)
dt = app.localtime(dt, from_utc=False)
# value is now "aware/local" so can proceed
utc = app.make_utc(dt)

The result of all examples shown above (8:15 PM UTC):

>>> utc
datetime.datetime(2025, 12, 16, 20, 15)

Convert to Local Time

When a datetime value is read from the app DB, it must be converted (from naive/UTC) to aware/local for display to user.

Below are 4 examples for converting values to local time zone. In short, use localtime() - but if providing a naive value, you should specify from_utc param as needed.

These examples assume America/Chicago (UTC-0600) for the “local” timezone, with a local time value of 2:15 PM (so, 8:15 PM UTC):

# naive/UTC => aware/local
# nb. value has no timezone but is already correct (8:15 PM UTC)
dt = datetime.datetime(2025, 12, 16, 20, 15)
# nb. can omit from_utc since it is assumed for naive values
local = app.localtime(dt)
# nb. or, specify it explicitly anyway
local = app.localtime(dt, from_utc=True)

# aware/UTC => aware/local
# nb. value has expicit timezone, w/ correct time (8:15 PM UTC)
dt = datetime.datetime(2025, 12, 16, 20, 15, tzinfo=datetime.timezone.utc)
local = app.localtime(dt)

# aware/local => aware/local
# (nb. this conversion is not actually needed of course, but we
# show the example to be thorough)
tzlocal = app.get_timezone()
# nb. value has expicit timezone, w/ correct time (2:15 PM local)
dt = datetime.datetime(2025, 12, 16, 14, 15, tzinfo=tzlocal)
# nb. the input and output values are the same here, both aware/local
local = app.localtime(dt)

If your value is naive/local then you can’t simply pass it to localtime() with no qualifiers - since that assumes naive values are already UTC by default.

Instead specify from_utc=False to ensure the value is interpreted correctly:

# naive/local => aware/local
# nb. value has no timezone but is correct for local zone (2:15 PM)
dt = datetime.datetime(2025, 12, 16, 14, 15)
# nb. must specify from_utc to avoid misinterpretation
local = app.localtime(dt, from_utc=False)

The result of all examples shown above (2:15 PM local):

>>> local
datetime.datetime(2025, 12, 16, 14, 15, tzinfo=zoneinfo.ZoneInfo("America/Chicago"))

Displaying to the User

Whenever a datetime should be displayed to the user, call render_datetime(). That will (by default) convert the value to aware/local and then render it using a common format.

(Once again, this will not work for naive/local values. Those must be explicitly converted to aware/local since the framework assumes all naive values are in UTC.)

You can specify local=False when calling render_datetime() to avoid its default conversion.

See also Convert to Local Time (above) for examples of how to convert any value to aware/local.

# naive/UTC
dt = app.make_utc()
print(app.render_datetime(dt))

# aware/UTC
dt = app.make_utc(tzinfo=True)
print(app.render_datetime(dt))

# aware/local
dt = app.localtime()
print(app.render_datetime(dt))

# naive/local
dt = datetime.datetime.now()
# nb. must explicitly convert to aware/local
dt = app.localtime(dt, from_utc=False)
# nb. can skip redundant conversion via local=False
print(app.render_datetime(dt, local=False))

Within the Database

This section describes storage and access details for datetime values within the app database.

Column Type

There is not a consistent/simple way to store timezone for datetime values in all database backends. Therefore we must always store the values as naive/UTC so app logic can reliably interpret them. (Hence that particular rule.)

All built-in data models use the DateTime column type (for datetime fields), with its default behavior. Any app schema extensions should (usually) do the same.

Writing to the DB

When a datetime value is written to the app DB, it must be naive/UTC.

See Convert to UTC (above) for examples of how to convert any value to naive/UTC.

Apps typically “write” data via the ORM. Regardless, the key point is that you should only pass naive/UTC values into the DB:

model = app.model
session = app.make_session()

# obtain aware/local value (for example)
tz = app.get_timezone()
dt = datetime.datetime(2025, 12, 16, 14, 15, tzinfo=tz)

# convert to naive/UTC when passing to DB
sprocket = model.Sprocket()
sprocket.some_dt_attr = app.make_utc(dt)
sprocket.created = app.make_utc() # "now"
session.add(sprocket)

session.commit()

Reading from the DB

Nothing special happens when reading datetime values from the DB; they will be naive/UTC just like they were written:

sprocket = session.get(model.Sprocket, uuid)

# these will be naive/UTC
dt = sprocket.some_dt_attr
dt2 = sprocket.created