Skip to main content

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 and repr() is for developers and machines
  • __format__ - custom format specifications in f-strings and format()
  • The three f-string conversion flags: !r, !s, !a
  • __bytes__ - binary representation
  • How pprint uses __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 - understanding self state
  • 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:

MethodCallableForContract
__repr__repr(obj)Developers, machines, debuggingIdeally eval-able; always unambiguous
__str__str(obj), print()End users, logsReadable 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.

warning

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.

tip

__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.

note

The !r and !s flags in f-strings are fundamentally different things:

  • f"{value!r}" is equivalent to f"{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 to f"{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 None or 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:

  1. Include enough state to identify the object uniquely
  2. Show the values that matter for debugging, not all values
  3. Be concise enough to be readable in a log line
  4. Use !r for 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}"
)

o = Order("ord-7842", "[email protected]", 129.99)

# 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__

u = User("alice", "[email protected]", "hash:$2b$12$...", "key-abc123")
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:

  1. When Python prints a list, which representation does it use for each element - __str__ or __repr__?
  2. What does print(obj) call? What does the REPL display when you type obj and press Enter?
  3. What is the eval-able string contract for __repr__, and when is it appropriate to break it?
  4. If you only define __repr__, what happens when str(obj) is called?
  5. What are the three f-string conversion flags and what does each one do?
  6. What method does f"{obj:spec}" call on obj?
  7. In what two scenarios should you use !r in your log messages?
  8. What does field(repr=False) do in a dataclass?
  9. 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:

  1. Wraps any value (passed to __init__)
  2. __repr__ returns the eval-able form: LoggedValue(42) or LoggedValue('hello')
  3. __str__ returns just the value formatted nicely: 42 or hello
  4. __format__ delegates format specs to the wrapped value: f"{lv:.2f}" should work if the value is a float
  5. Keeps a history list of all values it has been set to (via a set_value method)
  6. __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 for str(), 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 !r for 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) use repr() on their elements - not str(). Implementing only __str__ will not help when your object is inside a list.
  • The three f-string conversion flags are !r (calls repr()), !s (calls str()), and !a (calls ascii(), escaping non-ASCII characters). Use !r in log messages to make empty strings, None, and whitespace-only values visible.
  • __format__ powers f"{obj:spec}" and format(obj, spec). Implement it to give your objects custom format specifications the same way float supports :.2f.
  • Exclude sensitive fields (passwords, API keys, tokens) from __repr__ - they will appear in logs, crash reports, and test output.
  • @dataclass auto-generates __repr__; use field(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.

© 2026 EngineersOfAI. All rights reserved.