Representation and String Methods - __repr__, __str__, __format__ at Engineering Depth
Reading time: ~28 minutes | Level: Intermediate → Engineering
Before reading further, predict every output:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(3, 4)
print(p) # ?
print(repr(p)) # ?
print(str(p)) # ?
print(f"Point: {p}") # ?
print(f"Debug: {p!r}") # ?
items = [p, p]
print(items) # ?
All six lines produce output like <__main__.Point object at 0x7f...>. Every one of them. The angle-bracket form is Python's fallback when you have not defined the representation protocol.
Now consider what a production log entry looks like with that output:
ERROR processing request: <__main__.Order object at 0x7f4a3c8b1f50>
versus what it should look like:
ERROR processing request: Order(id='ord-7842', user='[email protected]', total=129.99, status='pending')
The difference between those two log lines is the difference between spending 30 seconds or 30 minutes debugging a production incident. Good representation is not cosmetic - it is an operational necessity.
This lesson covers exactly how to implement it correctly.
What You Will Learn
- What
__repr__and__str__are for, and when Python calls each one - The engineering contract that
repr()should return an eval-able string - Why
str()is for humans andrepr()is for developers and machines __format__- custom format specifications in f-strings andformat()- The three f-string conversion flags:
!r,!s,!a __bytes__- binary representation- How
pprintuses__repr__ - Logging and debugging patterns that depend on great
__repr__
Prerequisites
- Lesson 01: Classes and Objects - instance namespace,
__dict__ - Lesson 02:
__init__and Object Construction - understandingselfstate - Lesson 03: Dunder Methods - the protocol system concept
- Familiarity with Python f-strings and format specifications
Part 1 - What __repr__ and __str__ Are For
The Two Representations
Python distinguishes two kinds of string representation:
| Method | Callable | For | Contract |
|---|---|---|---|
__repr__ | repr(obj) | Developers, machines, debugging | Ideally eval-able; always unambiguous |
__str__ | str(obj), print() | End users, logs | Readable and human-friendly |
import datetime
d = datetime.datetime(2025, 7, 4, 12, 0, 0)
print(repr(d)) # datetime.datetime(2025, 7, 4, 12, 0, 0)
print(str(d)) # 2025-07-04 12:00:00
repr() shows you exactly how to recreate the object. str() shows you the value in a human-readable form.
When Python Calls Each
Python has explicit rules for which representation gets called in which context:
class Demo:
def __repr__(self):
return "Demo(repr)"
def __str__(self):
return "Demo(str)"
d = Demo()
print(d) # Demo(str) - print() calls str()
print(str(d)) # Demo(str) - explicit str()
print(repr(d)) # Demo(repr) - explicit repr()
print(f"{d}") # Demo(str) - f-string default calls str()
print(f"{d!r}") # Demo(repr) - !r conversion flag calls repr()
print(f"{d!s}") # Demo(str) - !s conversion flag calls str()
items = [d]
print(items) # [Demo(repr)] - containers use repr() for elements
print({d: 1}) # {Demo(repr): 1} - dicts use repr() for keys and values
# In the interactive REPL:
# >>> d
# Demo(repr) - REPL displays repr()
The critical insight: when an object is inside a container (list, dict, tuple, set), Python uses repr() on the element, not str(). This means even if you only implement __str__, the representation inside lists will still be the angle-bracket fallback.
The Fallback Chain
class OnlyRepr:
def __repr__(self):
return "OnlyRepr()"
class OnlyStr:
def __str__(self):
return "OnlyStr string"
r = OnlyRepr()
s = OnlyStr()
# If __str__ is not defined, str() falls back to __repr__
print(str(r)) # "OnlyRepr()" - falls back to repr
print(repr(r)) # "OnlyRepr()"
# If __repr__ is not defined, repr() uses the default <ClassName object at 0x...>
print(str(s)) # "OnlyStr string"
print(repr(s)) # "<__main__.OnlyStr object at 0x...>" - default
Consequence: if you only implement one, implement __repr__. It serves as the fallback for str() and it is the representation you will see in the REPL, in container prints, and in log output from frameworks.
Not defining __repr__ means every debugging session, every log line, and every container print will show a useless memory address like <__main__.Order object at 0x7f4a3c8b1f50>. You cannot tell what the object contains, what its state is, or even which instance you are looking at. Always define __repr__ first.
Part 2 - The __repr__ Engineering Contract
The Eval-Able String Rule
The standard for __repr__ is stated in the Python documentation:
If at all possible, this should look like a valid Python expression that could be used to recreate an object with the same value (given an appropriate environment).
class Color:
def __init__(self, r: int, g: int, b: int):
self.r = r
self.g = g
self.b = b
def __repr__(self):
return f"Color(r={self.r}, g={self.g}, b={self.b})"
c = Color(255, 128, 0)
print(repr(c)) # Color(r=255, g=128, b=0)
# The eval contract: eval(repr(c)) should recreate c
# eval("Color(r=255, g=128, b=0)") → a new Color(255, 128, 0)
When you write repr(obj) and the output is a valid Python expression, developers can paste it directly into a REPL or test to recreate the exact object. This is extremely powerful for debugging.
__repr__ should always produce a string that looks like a valid constructor call: ClassName(arg1=val1, arg2=val2). Use !r on every string value inside __repr__ so that strings appear quoted in the output. Without it, Color(r=255, g=red) looks like red is a variable name, not a string - the eval contract breaks.
When the Eval Contract Cannot Be Met
Not all objects can be faithfully reconstructed from a string. In those cases, use the angle-bracket convention to signal that the string is informational, not eval-able:
import socket
class Connection:
def __init__(self, host: str, port: int):
self.host = host
self.port = port
self._socket = None # live socket - cannot be repr'd as eval-able
def __repr__(self):
status = "connected" if self._socket else "disconnected"
# Use <...> to signal this is NOT eval-able
return f"<Connection host={self.host!r} port={self.port} status={status!r}>"
conn = Connection("db.example.com", 5432)
print(repr(conn))
# <Connection host='db.example.com' port=5432 status='disconnected'>
The angle brackets communicate: "this is a description for developers, not a constructor call." The Python standard library uses this convention for file objects, sockets, locks, and other stateful resources.
A Fully Correct __repr__
from typing import Optional
class HttpRequest:
def __init__(
self,
method: str,
url: str,
headers: Optional[dict] = None,
body: Optional[bytes] = None,
):
self.method = method.upper()
self.url = url
self.headers = headers or {}
self.body = body
def __repr__(self):
# Include all constructor arguments so eval(repr(x)) recreates x
parts = [f"method={self.method!r}", f"url={self.url!r}"]
if self.headers:
parts.append(f"headers={self.headers!r}")
if self.body is not None:
parts.append(f"body={self.body!r}")
return f"HttpRequest({', '.join(parts)})"
req = HttpRequest("get", "https://api.example.com/users", headers={"Authorization": "Bearer tok123"})
print(repr(req))
# HttpRequest(method='GET', url='https://api.example.com/users', headers={'Authorization': 'Bearer tok123'})
The !r in f"method={self.method!r}" ensures string values are quoted in the output. Without it:
# Without !r:
f"method={self.method}" → "method=GET" (not valid Python: GET is a name)
# With !r:
f"method={self.method!r}" → "method='GET'" (valid Python string literal)
Part 3 - __str__ for Human Output
When __str__ Is Worth Defining Separately
Define __str__ when the human-readable form is substantially different from the developer form:
from datetime import datetime, timezone
class Invoice:
def __init__(self, invoice_id: str, amount: float, created_at: datetime):
self.invoice_id = invoice_id
self.amount = amount
self.created_at = created_at
def __repr__(self):
# For developers - precise, reconstructable
return (
f"Invoice("
f"invoice_id={self.invoice_id!r}, "
f"amount={self.amount!r}, "
f"created_at={self.created_at!r}"
f")"
)
def __str__(self):
# For humans - readable, formatted
return (
f"Invoice #{self.invoice_id}\n"
f" Amount: ${self.amount:,.2f}\n"
f" Created: {self.created_at.strftime('\%B \%d, \%Y at \%H:\%M UTC')}"
)
inv = Invoice(
"INV-2025-0042",
1299.99,
datetime(2025, 7, 4, 14, 30, tzinfo=timezone.utc)
)
print(repr(inv))
# Invoice(invoice_id='INV-2025-0042', amount=1299.99, created_at=datetime.datetime(2025, 7, 4, 14, 30, tzinfo=datetime.timezone.utc))
print(str(inv))
# Invoice #INV-2025-0042
# Amount: $1,299.99
# Created: July 04, 2025 at 14:30 UTC
print(inv) # same as str(inv) - print() calls __str__
Part 4 - The __format__ Method
Custom Format Specifications
__format__ is called by format(obj, spec) and by f-strings when you use a format spec:
# f"{obj:spec}" calls obj.__format__(spec)
# format(obj, spec) also calls obj.__format__(spec)
This lets you define custom format specifications for your objects, the same way float supports :.2f and datetime supports :%Y-%m-%d:
class Temperature:
def __init__(self, celsius: float):
self.celsius = celsius
@property
def fahrenheit(self) -> float:
return self.celsius * 9 / 5 + 32
@property
def kelvin(self) -> float:
return self.celsius + 273.15
def __format__(self, spec: str) -> str:
if spec == "C" or spec == "":
return f"{self.celsius:.1f}°C"
elif spec == "F":
return f"{self.fahrenheit:.1f}°F"
elif spec == "K":
return f"{self.kelvin:.2f}K"
elif spec == "all":
return f"{self.celsius:.1f}°C / {self.fahrenheit:.1f}°F / {self.kelvin:.2f}K"
else:
raise ValueError(f"Unknown format spec {spec!r} for Temperature")
def __repr__(self):
return f"Temperature({self.celsius!r})"
def __str__(self):
return f"{self.celsius:.1f}°C"
t = Temperature(100)
print(f"{t}") # 100.0°C - no spec, calls __str__ via default
print(f"{t:C}") # 100.0°C
print(f"{t:F}") # 212.0°F
print(f"{t:K}") # 373.15K
print(f"{t:all}") # 100.0°C / 212.0°F / 373.15K
print(format(t, "F")) # 212.0°F
Combining Built-in Format Specs with Custom Logic
You can parse the format spec and delegate numeric formatting to Python's built-in format:
class Money:
def __init__(self, amount: float, currency: str = "USD"):
self.amount = amount
self.currency = currency
_CURRENCY_SYMBOLS = {
"USD": "$",
"EUR": "€",
"GBP": "£",
"JPY": "¥",
}
def __format__(self, spec: str) -> str:
symbol = self._CURRENCY_SYMBOLS.get(self.currency, self.currency)
if spec == "" or spec == "s":
# Default: symbol + 2 decimal places
return f"{symbol}{self.amount:,.2f}"
elif spec == "long":
return f"{self.amount:,.2f} {self.currency}"
elif spec == "compact":
if abs(self.amount) >= 1_000_000:
return f"{symbol}{self.amount / 1_000_000:.1f}M"
elif abs(self.amount) >= 1_000:
return f"{symbol}{self.amount / 1_000:.1f}K"
return f"{symbol}{self.amount:.0f}"
else:
# Delegate numeric spec to float \text{---} allows :.4f etc.
return f"{symbol}{self.amount:{spec}}"
def __repr__(self):
return f"Money({self.amount!r}, {self.currency!r})"
def __str__(self):
return format(self)
m = Money(1_234_567.89, "USD")
print(f"{m}") # $1,234,567.89
print(f"{m:long}") # 1,234,567.89 USD
print(f"{m:compact}") # $1.2M
print(f"{m:.4f}") # $1234567.8900
print(f"Total: {m:s}") # Total: $1,234,567.89
eur = Money(42.5, "EUR")
print(f"{eur}") # €42.50
Part 5 \text{---} The Three F-String Conversion Flags
!r, !s, !a Explained
F-strings support three conversion flags that transform the value before formatting:
class Example:
def __repr__(self): return "Example(repr)"
def __str__(self): return "Example(str)"
e = Example()
# !s \text{---} applies str() before formatting
print(f"{e!s}") # Example(str)
# !r \text{---} applies repr() before formatting
print(f"{e!r}") # Example(repr)
# !a \text{---} applies ascii() before formatting (escapes non-ASCII characters)
print(f"{e!a}") # Example(repr) (same if all ASCII; differs for Unicode)
The !a conversion is for contexts where you need ASCII-safe output:
name = "André"
city = "北京"
print(f"{name!s}") # André \text{---} UTF-8 preserved
print(f"{name!r}") # 'André' \text{---} quoted, UTF-8 preserved
print(f"{name!a}") # 'Andr\xe9' \text{---} non-ASCII escaped
print(f"{city!s}") # 北京
print(f"{city!r}") # '北京'
print(f"{city!a}") # '\u5317\u4eac' \text{---} fully escaped
!a is equivalent to calling ascii(), which is like repr() but escapes all non-ASCII characters.
The !r and !s flags in f-strings are fundamentally different things:
f"{value!r}"is equivalent tof"{repr(value)}"\text{---} it calls__repr__, wraps strings in quotes, and is ideal for log messages and error text where you need to see exact boundaries.f"{value!s}"is equivalent tof"{str(value)}"\text{---} it calls__str__, the default human-readable form.
In practice: use !r whenever you log values that might be empty strings, None, or contain whitespace \text{---} the quotes make ambiguous values visible.
Combining Conversion Flags with Format Specs
You can use a conversion flag and a format spec together \text{---} the conversion runs first:
value = 3.14159
name = "pi"
# !r then format spec \text{---} repr() is called, then the result is formatted
# repr() of a float produces a string; string formatting with :>15 pads it
print(f"{value!r:>15}") # " 3.14159"
# Practical: log a value with repr for safety, right-aligned
error_msg = "file not found"
print(f"ERROR: {error_msg!r:>30}") # ERROR: 'file not found'
When to Use !r in Practice
Use !r in log messages and error messages to make string boundaries visible:
import logging
def process_config(config_path: str) -> None:
logging.info(f"Loading config from {config_path!r}")
# With !r: Loading config from '/etc/app/config.yaml'
# Without: Loading config from /etc/app/config.yaml
def validate_field(field_name: str, value: str) -> None:
if not value:
raise ValueError(
f"Field {field_name!r} must not be empty, got {value!r}"
# With !r: Field 'email' must not be empty, got ''
# You can see that value is an empty string, not None or missing
)
The quotes around strings in !r output reveal:
- Whether a value is
Noneor the string"None" - Whether a value is empty
""or contains whitespace" " - Whether a value has leading/trailing spaces
Part 6 \text{---} __bytes__
Binary Representation
__bytes__ is called by bytes(obj) and should return a bytes object representing the instance:
import struct
class Packet:
"""A binary network packet."""
HEADER = b"\xDE\xAD\xBE\xEF"
def __init__(self, message_type: int, payload: bytes):
self.message_type = message_type
self.payload = payload
def __bytes__(self) -> bytes:
# Header (4 bytes) + type (1 byte) + length (2 bytes big-endian) + payload
header = self.HEADER
type_byte = struct.pack("B", self.message_type)
length = struct.pack(">H", len(self.payload))
return header + type_byte + length + self.payload
@classmethod
def from_bytes(cls, data: bytes) -> "Packet":
if data[:4] != cls.HEADER:
raise ValueError("Invalid packet header")
message_type = data[4]
length = struct.unpack(">H", data[5:7])[0]
payload = data[7:7 + length]
return cls(message_type, payload)
def __repr__(self):
return f"Packet(message_type={self.message_type!r}, payload={self.payload!r})"
def __len__(self):
return 7 + len(self.payload) # header + type + length + payload
# Serialise to bytes
pkt = Packet(0x01, b"hello world")
raw = bytes(pkt)
print(raw.hex()) # deadbeef010b00 + payload hex
# Deserialise from bytes
pkt2 = Packet.from_bytes(raw)
print(repr(pkt2)) # Packet(message_type=1, payload=b'hello world')
__bytes__ is most commonly used in:
- Network protocols and serialisation
- Binary file formats
- Cryptographic operations that work with raw bytes
Part 7 \text{---} Logging and Production Debugging
How Bad __repr__ Costs You Time
# Actual production log with no __repr__:
2025-07-04 14:23:11 ERROR Failed to charge customer:
<__main__.PaymentIntent object at 0x7f4a3c8b1f50>
due to: <__main__.CardError object at 0x7f4a3c8b1e20>
# Time to debug: find the ID from logs, query database, reconstruct state...
# With good __repr__:
2025-07-04 14:23:11 ERROR Failed to charge customer:
PaymentIntent(id='pi_3KjR7h2eZvKYlo2C1234', amount=Money(129.99, 'USD'),
customer='cus_abc123', status='requires_action')
due to: CardError(code='card_declined', decline_code='insufficient_funds',
card_last4='4242', message='Your card has insufficient funds.')
# Time to debug: 30 seconds
Designing __repr__ for Production
A production-quality __repr__ should:
- Include enough state to identify the object uniquely
- Show the values that matter for debugging, not all values
- Be concise enough to be readable in a log line
- Use
!rfor all string values to show boundaries
from datetime import datetime, timezone
from typing import Optional
class Order:
def __init__(
self,
order_id: str,
user_email: str,
total: float,
currency: str = "USD",
status: str = "pending",
):
self.order_id = order_id
self.user_email = user_email
self.total = total
self.currency = currency
self.status = status
self.created_at = datetime.now(timezone.utc)
self._internal_processing_notes: list = [] # not for repr
def __repr__(self):
return (
f"Order("
f"id={self.order_id!r}, "
f"user={self.user_email!r}, "
f"total={self.total:.2f} {self.currency}, "
f"status={self.status!r}"
f")"
)
def __str__(self):
return (
f"Order #{self.order_id}\n"
f" Customer: {self.user_email}\n"
f" Total: {self.total:,.2f} {self.currency}\n"
f" Status: {self.status}"
)
# In a log line:
import logging
logging.basicConfig(level=logging.DEBUG)
logging.error(f"Payment failed for {o!r}")
# ERROR:root:Payment failed for Order(id='ord-7842', user='[email protected]', total=129.99 USD, status='pending')
# In a print statement:
print(o)
# Order #ord-7842
# Customer: [email protected]
# Total: 129.99 USD
# Status: pending
pprint and __repr__
Python's pprint module uses repr() for all objects. For deeply nested structures, pprint makes the output readable:
from pprint import pprint, pformat
class Config:
def __init__(self, name, settings):
self.name = name
self.settings = settings
def __repr__(self):
return f"Config(name={self.name!r}, settings={self.settings!r})"
configs = [
Config("prod", {"debug": False, "workers": 8, "timeout": 30}),
Config("staging", {"debug": True, "workers": 2, "timeout": 60}),
]
pprint(configs)
# [Config(name='prod', settings={'debug': False, 'timeout': 30, 'workers': 8}),
# Config(name='staging', settings={'debug': True, 'timeout': 60, 'workers': 2})]
# pformat returns the pretty-printed string
formatted = pformat(configs, width=40)
print(formatted)
pprint is a first-class debugging tool. Objects that implement __repr__ properly integrate with it automatically.
Part 8 \text{---} Practical Patterns
The Dataclass Auto-__repr__
@dataclass generates __repr__ automatically based on fields:
from dataclasses import dataclass, field
from typing import List
@dataclass
class LineItem:
product: str
quantity: int
unit_price: float
def total(self) -> float:
return self.quantity * self.unit_price
@dataclass
class Cart:
user_id: str
items: List[LineItem] = field(default_factory=list)
coupon: str = ""
def grand_total(self) -> float:
return sum(item.total() for item in self.items)
cart = Cart(
user_id="usr_123",
items=[
LineItem("Widget", 2, 9.99),
LineItem("Gadget", 1, 24.99),
],
coupon="SAVE10",
)
print(repr(cart))
# Cart(user_id='usr_123', items=[LineItem(product='Widget', quantity=2, unit_price=9.99),
# LineItem(product='Gadget', quantity=1, unit_price=24.99)], coupon='SAVE10')
For dataclasses where auto-generated __repr__ is too verbose or includes sensitive fields, use repr=False on specific fields:
from dataclasses import dataclass, field
@dataclass
class User:
username: str
email: str
password_hash: str = field(repr=False) # excluded from __repr__
api_key: str = field(repr=False) # excluded from __repr__
print(repr(u))
# User(username='alice', email='[email protected]')
# password_hash and api_key are NOT shown - security win
Truncating Long Values in __repr__
For objects with potentially large data (like query results or file contents), truncate in __repr__ to keep it readable:
class QueryResult:
def __init__(self, query: str, rows: list):
self.query = query
self.rows = rows
def __repr__(self):
MAX_QUERY_LEN = 50
MAX_ROWS_SHOWN = 3
q = self.query
if len(q) > MAX_QUERY_LEN:
q = q[:MAX_QUERY_LEN] + "..."
row_count = len(self.rows)
shown = self.rows[:MAX_ROWS_SHOWN]
suffix = f" ...+{row_count - MAX_ROWS_SHOWN} more" if row_count > MAX_ROWS_SHOWN else ""
return (
f"QueryResult("
f"query={q!r}, "
f"rows={shown!r}{suffix}, "
f"total={row_count}"
f")"
)
r = QueryResult(
"SELECT id, name, email FROM users WHERE active = TRUE ORDER BY created_at DESC",
[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"},
{"id": 3, "name": "Carol"}, {"id": 4, "name": "Dave"}]
)
print(repr(r))
# QueryResult(query='SELECT id, name, email FROM users WHERE active = T...',
# rows=[{'id': 1, 'name': 'Alice'}, {'id': 2, 'name': 'Bob'},
# {'id': 3, 'name': 'Carol'}] ...+1 more, total=4)
Building __repr__ Programmatically
For complex objects, build __repr__ from __dict__ or field annotations:
class AutoRepr:
"""Mixin that auto-generates __repr__ from instance __dict__."""
def __repr__(self):
cls_name = type(self).__name__
# Get all public attributes (exclude leading underscore)
attrs = {
k: v for k, v in self.__dict__.items()
if not k.startswith("_")
}
args = ", ".join(f"{k}={v!r}" for k, v in attrs.items())
return f"{cls_name}({args})"
class Product(AutoRepr):
def __init__(self, sku: str, name: str, price: float):
self.sku = sku
self.name = name
self.price = price
self._internal_id = id(self) # excluded (starts with _)
p = Product("SKU-001", "Widget Pro", 49.99)
print(repr(p))
# Product(sku='SKU-001', name='Widget Pro', price=49.99)
Common Mistakes
Mistake 1 - Implementing Only __str__
# Wrong - containers will show the default angle-bracket repr
class Config:
def __str__(self):
return f"Config({self.name})"
configs = [Config(), Config()]
print(configs) # [<__main__.Config object at 0x...>, ...]
# str() is not called for list elements - repr() is
# Right - always define __repr__; optionally also define __str__
class Config:
def __repr__(self):
return f"Config(name={self.name!r})"
def __str__(self): # optional - human-friendly format
return f"Config: {self.name}"
Mistake 2 - Forgetting !r for String Values in __repr__
# Wrong - string values look like bare names (not valid Python)
class Event:
def __repr__(self):
return f"Event(type={self.type}, name={self.name})"
# Output: Event(type=click, name=submit-button)
# Is 'click' a variable? A string? Ambiguous.
# Right - use !r to quote strings
class Event:
def __repr__(self):
return f"Event(type={self.type!r}, name={self.name!r})"
# Output: Event(type='click', name='submit-button')
# Unambiguously a string.
Mistake 3 - Returning Non-String from __repr__ or __str__
# Wrong - TypeError when Python calls repr()
class Bad:
def __repr__(self):
return 42 # TypeError: __repr__ returned non-string (type int)
# Right
class Good:
def __repr__(self):
return "Good()" # must always return str
Mistake 4 - Including Sensitive Data in __repr__
# Wrong - API keys and passwords leak into logs and REPL output
class APIClient:
def __repr__(self):
return f"APIClient(url={self.url!r}, api_key={self.api_key!r})"
# Output: APIClient(url='https://api.example.com', api_key='sk-prod-abc123xyz')
# Right - mask or exclude sensitive fields
class APIClient:
def __repr__(self):
key_hint = f"...{self.api_key[-4:]}" if self.api_key else "None"
return f"APIClient(url={self.url!r}, api_key={key_hint!r})"
# Output: APIClient(url='https://api.example.com', api_key='...xyz')
Mistake 5 - Recursion in __repr__
# Wrong - if obj.parent also has a __repr__ that includes its child, infinite loop
class Node:
def __init__(self, value, parent=None):
self.value = value
self.parent = parent
def __repr__(self):
return f"Node(value={self.value!r}, parent={self.parent!r})"
# If parent also references this node → RecursionError
# Right - break cycles by showing identity or a summary
class Node:
def __repr__(self):
parent_repr = f"Node({self.parent.value!r})" if self.parent else None
return f"Node(value={self.value!r}, parent={parent_repr!r})"
Engineering Checklist
Before moving to the next lesson, verify you can answer these without looking:
- When Python prints a list, which representation does it use for each element -
__str__or__repr__? - What does
print(obj)call? What does the REPL display when you typeobjand press Enter? - What is the eval-able string contract for
__repr__, and when is it appropriate to break it? - If you only define
__repr__, what happens whenstr(obj)is called? - What are the three f-string conversion flags and what does each one do?
- What method does
f"{obj:spec}"call onobj? - In what two scenarios should you use
!rin your log messages? - What does
field(repr=False)do in a dataclass? - What method does
bytes(obj)call?
Quick Reference
class MyClass:
def __init__(self, name: str, value: int):
self.name = name
self.value = value
def __repr__(self) -> str:
# Eval-able form - use !r for strings
return f"MyClass(name={self.name!r}, value={self.value!r})"
def __str__(self) -> str:
# Human-readable - only define if different from repr
return f"{self.name}: {self.value}"
def __format__(self, spec: str) -> str:
if spec == "":
return str(self)
elif spec == "verbose":
return f"{type(self).__name__}(name={self.name!r}, value={self.value})"
else:
raise ValueError(f"Unknown format spec {spec!r}")
def __bytes__(self) -> bytes:
import struct
return self.name.encode() + struct.pack(">i", self.value)
# Usage
obj = MyClass("alpha", 42)
print(repr(obj)) # MyClass(name='alpha', value=42)
print(str(obj)) # alpha: 42
print(f"{obj}") # alpha: 42
print(f"{obj!r}") # MyClass(name='alpha', value=42)
print(f"{obj!s}") # alpha: 42
print(f"{obj:verbose}") # MyClass(name='alpha', value=42)
print(bytes(obj)) # b'alpha\x00\x00\x00*'
Graded Practice
Level 1 - Predict the Output
Given this class with no __repr__ or __str__ defined initially, predict what each line prints. Then check your answers.
class Sensor:
def __init__(self, name, reading):
self.name = name
self.reading = reading
s = Sensor("temp", 98.6)
# Question 1:
print(s)
# Question 2:
print(repr(s))
# Question 3:
print(f"Sensor: {s}")
# Question 4:
readings = [s, Sensor("pressure", 1013)]
print(readings)
# Question 5 - now __repr__ is added:
Sensor.__repr__ = lambda self: f"Sensor(name={self.name!r}, reading={self.reading})"
print(str(s))
Show Answer
# Question 1: print(s)
<__main__.Sensor object at 0x...>
# print() calls str(); no __str__, falls back to __repr__; no __repr__, falls back to default
# Question 2: print(repr(s))
<__main__.Sensor object at 0x...>
# repr() with no __repr__ gives the default angle-bracket form
# Question 3: print(f"Sensor: {s}")
Sensor: <__main__.Sensor object at 0x...>
# f-string default calls str(); same fallback chain
# Question 4: print(readings)
[<__main__.Sensor object at 0x...>, <__main__.Sensor object at 0x...>]
# containers use repr() on each element - both get the default
# Question 5: print(str(s)) - after __repr__ is added
Sensor(name='temp', reading=98.6)
# str() falls back to __repr__ when __str__ is not defined
# Now that __repr__ is defined, str() returns the __repr__ result
Key concept: the fallback chain is __str__ → __repr__ → default. Implementing __repr__ alone covers all contexts - containers, REPL, str(), print().
Level 2 - Debug Challenge
The following __repr__ implementation has three bugs. Find and fix all of them.
class Transaction:
def __init__(self, txn_id, amount, currency, status):
self.txn_id = txn_id
self.amount = amount
self.currency = currency
self.status = status
def __repr__(self):
return (
f"Transaction("
f"id={self.txn_id}, " # Bug 1
f"amount={self.amount:.2f}, "
f"currency={self.currency}, " # Bug 2
f"status={self.status!r}"
f")"
) # Bug 3: __repr__ returns None implicitly?
# Hint: look carefully at the return type contract
t = Transaction("txn-001", 49.99, "USD", "completed")
print(repr(t))
# Expected: Transaction(id='txn-001', amount=49.99, currency='USD', status='completed')
# Actual output might surprise you
Show Answer
Bug 1: id={self.txn_id} - the txn_id is a string ("txn-001") but without !r it appears unquoted: id=txn-001. This looks like a variable name, not a string. It also breaks the eval contract. Fix: id={self.txn_id!r}.
Bug 2: currency={self.currency} - same problem: currency=USD looks like a bare name. Fix: currency={self.currency!r}.
Bug 3: There is no actual missing return here - the return is present. But note that amount={self.amount:.2f} uses a format spec inside __repr__, which is fine for readability but breaks the eval contract: amount=49.99 is valid Python, so this is acceptable. However, if amount were 49.9900000001 due to float precision, the truncated .2f would not reconstruct the exact value. For a strict eval-able repr, use {self.amount!r} instead of {self.amount:.2f}.
Corrected version:
def __repr__(self):
return (
f"Transaction("
f"id={self.txn_id!r}, "
f"amount={self.amount!r}, "
f"currency={self.currency!r}, "
f"status={self.status!r}"
f")"
)
# Output: Transaction(id='txn-001', amount=49.99, currency='USD', status='completed')
Level 3 - Design Challenge
Design a LoggedValue class that:
- Wraps any value (passed to
__init__) __repr__returns the eval-able form:LoggedValue(42)orLoggedValue('hello')__str__returns just the value formatted nicely:42orhello__format__delegates format specs to the wrapped value:f"{lv:.2f}"should work if the value is a float- Keeps a history list of all values it has been set to (via a
set_valuemethod) __repr__includes the history length:LoggedValue(42, history=3)
Show Answer
class LoggedValue:
def __init__(self, value):
self._value = value
self._history = [value]
def set_value(self, new_value):
self._history.append(new_value)
self._value = new_value
@property
def value(self):
return self._value
@property
def history(self):
return list(self._history)
def __repr__(self) -> str:
history_len = len(self._history)
if history_len == 1:
return f"LoggedValue({self._value!r})"
return f"LoggedValue({self._value!r}, history={history_len})"
def __str__(self) -> str:
return str(self._value)
def __format__(self, spec: str) -> str:
# Delegate to the wrapped value's __format__
return format(self._value, spec)
# Demo
lv = LoggedValue(3.14159)
print(repr(lv)) # LoggedValue(3.14159)
print(str(lv)) # 3.14159
print(f"{lv:.2f}") # 3.14 - delegates to float.__format__
lv.set_value(2.71828)
lv.set_value(1.41421)
print(repr(lv)) # LoggedValue(1.41421, history=3)
print(lv.history) # [3.14159, 2.71828, 1.41421]
# Works in containers - repr() is used
values = [LoggedValue(x) for x in [10, 20, 30]]
print(values)
# [LoggedValue(10), LoggedValue(20), LoggedValue(30)]
# Logging integration
import logging
logging.basicConfig(level=logging.INFO)
logging.info(f"Current value: {lv!r}")
# INFO:root:Current value: LoggedValue(1.41421, history=3)
Key Takeaways
- Always define
__repr__first. It is the fallback forstr(), the representation shown in the REPL, inside containers, and in most logging frameworks. Skipping it means every debugging context shows a useless memory address. __repr__should produce a valid Python expression that recreates the object -ClassName(arg1=val1, arg2=val2). When this is impossible (stateful resources like sockets), use angle brackets to signal non-eval-ability.__str__is optional. Define it only when the human-readable form is substantially different from the developer form.- Always use
!rfor string values inside__repr__so they appear quoted. Without it, bare strings look like variable names and the eval contract breaks. - Containers (
list,dict,tuple,set) userepr()on their elements - notstr(). Implementing only__str__will not help when your object is inside a list. - The three f-string conversion flags are
!r(callsrepr()),!s(callsstr()), and!a(callsascii(), escaping non-ASCII characters). Use!rin log messages to make empty strings,None, and whitespace-only values visible. __format__powersf"{obj:spec}"andformat(obj, spec). Implement it to give your objects custom format specifications the same wayfloatsupports:.2f.- Exclude sensitive fields (passwords, API keys, tokens) from
__repr__- they will appear in logs, crash reports, and test output. @dataclassauto-generates__repr__; usefield(repr=False)to exclude individual fields.
What's Next
Lesson 05 covers encapsulation and data hiding - @property, name mangling, __slots__, and the descriptor protocol that powers @property, @classmethod, and @staticmethod internally.
You will learn the precise difference between a single underscore (_name) and double underscore (__name) convention, when to use @property vs a plain attribute, how to write validation in setters, and how descriptors work at the protocol level - which explains how every attribute access in Python actually functions.
