Python
async programming
__str__ method
asynchronous functions
Python properties

Call a async function/property inside __str__

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Python's __str__ method cannot be async because the Python data model requires it to return a str synchronously. When print(obj) or str(obj) is called, Python expects an immediate string result — not a coroutine. If you need to include data from an async source in your string representation, you must either cache the async data beforehand, use asyncio.run() (if no event loop is running), or design your class with a separate async method that returns a string. There is no way to make __str__ itself asynchronous.

The Problem

python
1import asyncio
2
3class User:
4    def __init__(self, user_id):
5        self.user_id = user_id
6
7    async def fetch_name(self):
8        await asyncio.sleep(0.1)  # Simulate async DB call
9        return f"User_{self.user_id}"
10
11    # WRONG: __str__ cannot be async
12    async def __str__(self):
13        name = await self.fetch_name()
14        return f"User(name={name})"
15
16user = User(42)
17print(user)
18# <coroutine object User.__str__ at 0x...>
19# RuntimeWarning: coroutine 'User.__str__' was never awaited

When __str__ is async, calling str(user) returns the coroutine object instead of a string, because Python does not await it.

Fetch the async data ahead of time and store it in the object:

python
1import asyncio
2
3class User:
4    def __init__(self, user_id):
5        self.user_id = user_id
6        self.name = None  # Populated by async init
7
8    async def load(self):
9        """Fetch async data and cache it."""
10        await asyncio.sleep(0.1)  # Simulate DB call
11        self.name = f"User_{self.user_id}"
12        return self
13
14    def __str__(self):
15        if self.name is None:
16            return f"User(id={self.user_id}, not loaded)"
17        return f"User(name={self.name})"
18
19# Usage
20async def main():
21    user = await User(42).load()
22    print(user)  # User(name=User_42)
23
24asyncio.run(main())

This is the cleanest approach — async loading happens once, and __str__ reads cached data synchronously.

Solution 2: Factory Pattern with async classmethod

python
1import asyncio
2
3class User:
4    def __init__(self, user_id, name):
5        self.user_id = user_id
6        self.name = name
7
8    @classmethod
9    async def create(cls, user_id):
10        """Async factory that fetches data before construction."""
11        await asyncio.sleep(0.1)  # Simulate DB call
12        name = f"User_{user_id}"
13        return cls(user_id, name)
14
15    def __str__(self):
16        return f"User(name={self.name})"
17
18async def main():
19    user = await User.create(42)
20    print(user)  # User(name=User_42)
21
22asyncio.run(main())

The factory pattern ensures the object is fully initialized before it is used, so __str__ always has the data it needs.

Solution 3: Separate Async String Method

python
1import asyncio
2
3class Report:
4    def __init__(self, report_id):
5        self.report_id = report_id
6
7    async def async_str(self):
8        """Async alternative to __str__."""
9        data = await self.fetch_data()
10        return f"Report(id={self.report_id}, rows={len(data)})"
11
12    async def fetch_data(self):
13        await asyncio.sleep(0.1)
14        return [1, 2, 3, 4, 5]
15
16    def __str__(self):
17        return f"Report(id={self.report_id})"
18
19    def __repr__(self):
20        return f"Report({self.report_id!r})"
21
22async def main():
23    report = Report(1)
24    # Sync representation (no data)
25    print(report)                     # Report(id=1)
26    # Async representation (with data)
27    print(await report.async_str())   # Report(id=1, rows=5)
28
29asyncio.run(main())

Solution 4: asyncio.run() Inside str (Use With Caution)

python
1import asyncio
2
3class User:
4    def __init__(self, user_id):
5        self.user_id = user_id
6
7    async def fetch_name(self):
8        await asyncio.sleep(0.1)
9        return f"User_{self.user_id}"
10
11    def __str__(self):
12        try:
13            loop = asyncio.get_running_loop()
14        except RuntimeError:
15            # No event loop running — safe to use asyncio.run()
16            name = asyncio.run(self.fetch_name())
17            return f"User(name={name})"
18        else:
19            # Event loop already running — cannot nest asyncio.run()
20            return f"User(id={self.user_id}, name=pending)"
21
22# Works outside async context
23user = User(42)
24print(user)  # User(name=User_42)

This approach only works when no event loop is running. Inside an async function, asyncio.run() raises RuntimeError: This event loop is already running.

Solution 5: Using await Protocol

For objects that represent async operations, you can implement __await__:

python
1import asyncio
2
3class AsyncUser:
4    def __init__(self, user_id):
5        self.user_id = user_id
6        self.name = None
7
8    def __await__(self):
9        return self._init().__await__()
10
11    async def _init(self):
12        await asyncio.sleep(0.1)
13        self.name = f"User_{self.user_id}"
14        return self
15
16    def __str__(self):
17        return f"User(name={self.name or 'unloaded'})"
18
19async def main():
20    user = await AsyncUser(42)
21    print(user)  # User(name=User_42)
22
23asyncio.run(main())

Common Pitfalls

  • Making __str__ async: Python's data model requires __str__ to return a str synchronously. Defining async def __str__ returns a coroutine object when called, not a string. There is no way to make dunder methods async.
  • Calling asyncio.run() inside a running event loop: If __str__ is called from within an async context (which already has an event loop), asyncio.run() raises RuntimeError. Always check for a running loop with asyncio.get_running_loop() before calling asyncio.run().
  • Forgetting to await the factory method: Using user = User.create(42) without await assigns a coroutine to user, not a User instance. Always await async factory methods.
  • Blocking the event loop with synchronous I/O in __str__: Replacing async calls with synchronous equivalents (like requests.get() instead of aiohttp) inside __str__ blocks the entire event loop if called from async code. Cache data or use a separate async method instead.
  • Not providing a sync fallback in __str__: If async data is not yet loaded, __str__ should return a useful fallback (like the object ID) rather than raising an exception. Users expect print(obj) to always work.

Summary

  • __str__ must be synchronous — Python's data model does not support async def __str__
  • Cache async data in the object before __str__ is called (recommended approach)
  • Use an async factory method (@classmethod async def create()) to ensure data is loaded at construction time
  • Create a separate async_str() method when you need async data in the string representation
  • asyncio.run() inside __str__ only works when no event loop is already running
  • Always provide a synchronous fallback in __str__ for when async data has not been loaded

Course illustration
Course illustration

All Rights Reserved.