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:

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:

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.

Because of that, comparing naive with aware behaves like this:

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

Serialized output also changes:

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:

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:

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:

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:

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.