Debugging Python f-string errors
April 26, 2025
Today, I encountered a fun bug where f"{x}"
threw a TypeError, but str(x)
worked. Join me on my journey unravelling what f-strings do and uncovering the mystery of why an object might not be what it seems.
The initial problem
We have some code that deserializes data with a type hint, and I added a line that did:
raise ValueError(f"{value} does not match {type_hint}")
But when this branch was encountered, we got this cryptic error:
TypeError: descriptor '__format__' for 'datetime.date' objects doesn't apply to a 'str' object
After some debugging, I found the following behavior:
>>> print(type_hint)
# <class 'datetime.datetime'>
>>> print(str(type_hint))
# <class 'datetime.datetime'>
>>> print(f"{type_hint}")
# TypeError: descriptor '__format__' for 'datetime.date' objects doesn't apply to a 'str' object
What?? I thought all of these would be equivalent!
Debugging the error
How the heck is that error even triggered??
>>> from datetime import datetime
>>> datetime.now().__format__()
# TypeError: __format__() takes exactly 1 argument (0 given)
>>> datetime.now().__format__("")
# '2025-04-26 11:30:10.914804'
>>> datetime.__format__("")
# TypeError: descriptor '__format__' for 'datetime.date' objects doesn't apply to a 'str' object
Aha! So __format__
is meant to be called on an instance, so when called on a class with a string (that's meant to be the second arg after self
), we get this error that self
is supposed to be a date
object (datetime
inherits __format__
from date
). But why the heck is it being called that way?
Let's go another direction:
>>> print(datetime)
# <class 'datetime.datetime'>
>>> print(type_hint)
# <class 'datetime.datetime'>
>>> type_hint is datetime
# False
Wait, printing out type_hint
says it's a datetime
class object, but it's not equal to the datetime
object we imported ourselves??
>>> print(f"{datetime}")
# <class 'datetime.datetime'>
>>> print(f"{type_hint}")
# TypeError: descriptor '__format__' for 'datetime.date' objects doesn't apply to a 'str' object
Fun. Okay so the normal datetime
class itself interpolates fine, but not whatever type_hint
is, even though it claims to be datetime
.
Detour: what does f-string do?
At this point, it's clear that my mental model of f"{v}" == str(v)
is incorrect, so I tried digging into what f"{v}"
actually desugars to. After a couple hours of searching the docs and reading CPython source code (yeah, I was desperate), I finally found the breakdown.
f"{v}"
# ==>
format(v, "")
# ==>
type(v).__format__(v, "")
Turns out, there's a small line in the f-strings section (not to be confused with the Formatted String Literals section) that says:
The result is then formatted using the format() protocol.
Which specifies:
A call to
format(value, format_spec)
is translated totype(value).__format__(value, format_spec)
In most cases, __format__
with an empty format spec is equivalent to str()
... but of course, you can override it to be anything in a custom type. (~~ Foreshadowing ~~)
Revealing the bug
At this point, I went down multiple other rabbit holes. Sadly, it didn't occur to me to double check type(type_hint)
until later, which uncovered everything:
>>> type(type_hint)
# <class 'temporalio.worker.workflow_sandbox._restrictions._RestrictedProxy'>
Ah. This deserialization code was for deserializing messages sent via Temporal, which does some sandboxing around imports. So apparently, Temporal will replace the imported datetime
with a proxy, which is why, in most cases, type_hint
was indistinguishable from datetime
.
If we look at the source code, we can see that it proxies all magic methods through to a lookup class:
# Simplified for clarity
class _RestrictedProxy:
__format__ = _RestrictedProxyLookup("__format__")
def __init__(self, obj):
self.__obj = obj
class _RestrictedProxyLookup:
def __init__(self, name, access_func = None):
self.name = name
self.bind_func = access_func
def __call__(self, instance: _RestrictedProxy, *args, **kwargs):
obj = instance.__obj
if self.bind_func:
return self.bind_func(obj, *args, **kwargs)
else:
return getattr(obj, self.name)(*args, **kwargs)
The problem was that __format__
is not passing any function to the lookup object, which results in the following call stack:
f"{proxied_datetime}"
# ==> f-string desugaring
format(proxied_datetime, "")
type(proxied_datetime).__format__(proxied_datetime, "")
_RestrictedProxy.__format__(proxied_datetime, "")
# ==> result of __call__ when self.bind_func is None
getattr(real_datetime, "__format__")("")
# ==> evaluate getattr()
real_datetime.__format__("")
# TypeError: descriptor '__format__' for 'datetime.date' objects doesn't apply to a 'str' object
Turns out this was fixed in 1.10.0 (which we aren't on because we're still using Python 3.8) with a simple change:
class _RestrictedProxy:
- __format__ = _RestrictedProxyLookup("__format__")
+ __format__ = _RestrictedProxyLookup("__format__", format)
Now, self.bind_func
is no longer None
, which results in the correct call stack:
f"{proxied_datetime}"
# ==> f-string desugaring
format(proxied_datetime, "")
type(proxied_datetime).__format__(proxied_datetime, "")
_RestrictedProxy.__format__(proxied_datetime, "")
# ==> result of __call__ now that self.bind_func is the `format` function
format(real_datetime, "")
# <class 'datetime.datetime'>'
Alas, that's 3 hours I won't get back.