datetime.utcnow() Is Deprecated and Scheduled for Removal: Fix It Without Breaking Comparisons
Python · Intermediate · 7 min read · published
This article was written by Claude (Anthropic) and published automatically. Published by Osama Fayyaz.
What this solves: Python 3.12 deprecates datetime.utcnow(). The suggested fix, datetime.now(UTC), starts raising TypeError the moment it meets naive datetimes from your DB or cache.
What Changed
If your test output suddenly says DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC)., you are on Python 3.12 or later. Since 3.12, datetime.utcnow() and datetime.utcfromtimestamp() are deprecated, and they emit this warning every time they are called.
The replacement pieces already exist:
datetime.UTCis an alias fordatetime.timezone.utc, added in Python 3.11.datetime.now(tz)anddatetime.fromtimestamp(ts, tz)return aware datetimes, meaning thetzinfois set.
No removal version has been announced, so your code still runs today. The real trouble starts when you apply the suggested fix:
TypeError: can't compare offset-naive and offset-aware datetimes
That error is what this article is about.
The Old Way vs The New Way
from datetime import datetime, timedelta
now = datetime.utcnow() # naive: tzinfo is None
expires = now + timedelta(hours=1)
ts = datetime.utcfromtimestamp(1700000000) # naive
print(now.isoformat()) # '2025-03-01T10:00:00.123456'
print(now.timestamp()) # WRONG unless the server runs in UTC
from datetime import datetime, timedelta, UTC
now = datetime.now(UTC) # aware: tzinfo=UTC
expires = now + timedelta(hours=1)
ts = datetime.fromtimestamp(1700000000, UTC) # aware
print(now.isoformat()) # '2025-03-01T10:00:00.123456+00:00'
print(now.timestamp()) # correct on every machine
Why It Was Added: What Was Wrong With utcnow()?
utcnow() returns a datetime that holds UTC wall-clock numbers but carries no record that they are UTC. Every other part of Python treats a naive datetime as local time.
That mismatch produces a whole class of quiet bugs:
- Wrong timestamps.
datetime.utcnow().timestamp()interprets the value as local time. On a laptop in UTC+2 it is off by two hours. In a UTC container it happens to be correct. So the bug passes CI and appears on a developer machine, or the other way round. - Wrong conversions.
.astimezone(...)on a naive datetime assumes it is local time, so conversions shift by the server's offset. - Unclear serialized values. An
isoformat()string with no offset, once stored in a DB, cache, or API payload, gives downstream consumers no way to tell UTC from local time.
The core developers decided a function that creates misleading objects should not remain the obvious choice. Aware UTC datetimes have none of these problems.
How It Works Underneath: Why Does Mixing Them Raise TypeError?
A datetime is a set of fields plus an optional tzinfo.
- Aware (
tzinfoset): arithmetic and comparison convert both sides to UTC usingutcoffset()first. - Naive (
tzinfoisNone): there is no offset, so Python cannot place the value on the real timeline.
Because of that, comparing naive with aware behaves like this:
<,>,<=,>=and subtraction raiseTypeError.==does not raise. It returnsFalse. That is the dangerous case:if cached_at == loaded_atsilently stops matching.
flowchart TD
A["a < b"] --> B{"a.tzinfo set?"}
B -- no --> C{"b.tzinfo set?"}
B -- yes --> D{"b.tzinfo set?"}
C -- no --> E["Compare raw fields<br/>(assumes same zone)"]
C -- yes --> X["TypeError: can't compare<br/>offset-naive and offset-aware"]
D -- no --> X
D -- yes --> F["a - a.utcoffset() vs<br/>b - b.utcoffset()"]
F --> G["Correct timeline comparison"]
X -.-> H["Note: == returns False<br/>instead of raising"]
The usual sources of naive values that collide with your new aware now(UTC):
- SQLAlchemy
DateTime()columns withouttimezone=True, which map to Postgrestimestamp without time zone. - Django with
USE_TZ = False. fromisoformat()on strings that were written byutcnow().isoformat().strptime()with a format that has no%z.- Third-party libraries that still call
utcnow()internally.
Serialized output also changes:
isoformat()now appends+00:00.strftime('%z')now prints+0000instead of an empty string.
Any consumer that parses these strings, or any cache key built from them, will notice the difference.
Should You Adopt It Yet?
Yes. datetime.now(UTC) works on 3.11+. On older versions, use timezone.utc, which has been available since 3.2. There is no runtime cost. The cost is entirely the boundary audit described below.
How to approach it depending on your situation:
- Libraries supporting 3.8–3.10: use
datetime.now(timezone.utc)rather thanUTC. - Services with large persisted datasets of naive timestamps: make the change, but do the read-side normalization in the same PR.
- Don't silence the warning with a filter and move on. The removal is coming, and the warning points at real ambiguity in your data.
Replacing calls one by one without handling the boundaries is how a harmless warning turns into a production TypeError.
Migration Notes: What to Grep For and What Breaks
1. Find the calls.
grep -rnE "utcnow\(|utcfromtimestamp\(|datetime\.now\(\)|replace\(tzinfo=None\)|strptime\(" --include=*.py .
Two of these patterns are not deprecated but still need a look:
- Bare
datetime.now()is not deprecated, but it gives you naive local time, which is usually a bug too. replace(tzinfo=None)marks a place where someone deliberately stripped awareness.
2. Make the warning fail tests, so new uses can't creep back in:
# pyproject.toml
[tool.pytest.ini_options]
filterwarnings = ["error:datetime.datetime.utcnow:DeprecationWarning"]
If the warning comes from a dependency rather than your code, upgrade that dependency or add a narrowly scoped ignore for that module only.
3. Fix the boundaries before the call sites:
- SQLAlchemy: switch to
DateTime(timezone=True)and migrate the column withALTER TABLE ... ALTER COLUMN created_at TYPE timestamptz USING created_at AT TIME ZONE 'UTC'. psycopg returns aware datetimes fortimestamptzcolumns. - Django: make sure
USE_TZ = True, and usedjango.utils.timezone.now(). - Strings from caches, queues, and JSON: parse them through one helper that treats naive values as UTC. Old offset-less data will stay around until TTLs expire, and backups keep it even longer.
def as_utc(dt):
return dt.replace(tzinfo=UTC) if dt.tzinfo is None else dt.astimezone(UTC)
4. Check outbound formats. The new +00:00 suffix can break:
- strict regexes in other services
- string-sorted columns
- cache keys and signatures built from timestamp strings
If an API contract requires the old format, use an explicit strftime there instead of isoformat().
5. Audit == comparisons on datetimes. They won't raise. They will just quietly return False.
Key takeaway: Replace utcnow() with datetime.now(UTC), and convert every naive value to aware at your I/O boundaries in the same change, or you just trade a warning for a TypeError.
Real-world challenge
A team migrated an auth service from datetime.utcnow() to datetime.now(UTC) and deployed. Within minutes, some logins fail with `TypeError: can't compare offset-naive and offset-aware datetimes` inside `is_session_valid()`. Only users who logged in before the deploy are affected. Sessions are stored in Redis as JSON, and `expires_at` is serialized with `.isoformat()` and read back with `datetime.fromisoformat()`.
Diagnosis
The failure only hits sessions created before the deploy. Those were written with utcnow().isoformat(), which produces a string with no offset, such as 2025-03-01T10:00:00. New sessions are written as 2025-03-01T10:00:00+00:00.
fromisoformat() turns the old string into a naive datetime and the new one into an aware datetime. Comparing a naive value against datetime.now(UTC) raises the TypeError.
The code is correct. The stored data is mixed, and it will stay mixed until every old session expires.
Fix
Add one parsing helper at the read boundary. It treats any naive value as UTC, which is true for everything utcnow() ever produced:
from datetime import datetime, UTC
def parse_utc(s: str) -> datetime:
dt = datetime.fromisoformat(s)
if dt.tzinfo is None:
return dt.replace(tzinfo=UTC) # legacy utcnow() data
return dt.astimezone(UTC)
Route every fromisoformat call on stored data through parse_utc. Then add a test that loads a legacy offset-less string.
Once the longest session TTL has passed, the naive branch becomes dead code. Keep it anyway, because backups and replayed messages can still contain old strings.
Lesson: a datetime migration changes the shape of persisted data, not just the calls in your code. Look for every place a datetime is serialized and plan for old and new formats existing side by side.