The Python Data Model

How to unlock the power of Python’s special methods to build intuitive and consistent classes.
Python Academy
Published

December 12, 2025

Modified

February 20, 2026

Python Data Model cover

Python Data Model cover

Introduction

Python’s built-in types just… work. len() for sizes, [] for lookups, for loops for iteration. It all feels consistent because it is.

That consistency comes from the Python Data Model. By implementing a few “special methods” (you might know them as dunder methods, short for double underscore), you can make your own custom classes behave exactly like the built-ins.

In this post, we’re going to build a Portfolio class that feels like a native part of the language. Instead of clunky custom methods, we’ll use the patterns Python already knows and loves.

The Goal: a Pythonic Portfolio

We want a Portfolio class that stores a list of financial positions. But we don’t want a clunky interface like portfolio.get_position_by_ticker("AAPL"). We want the market-standard syntax:

  • len(portfolio) to size up our exposure.
  • portfolio["AAPL"] to get a quote instantly.
  • for position in portfolio to audit our holdings.

Let’s start by defining a simple Position container. To keep our balance sheet clean, we’ll track the total USD value rather than quantity and price.

Show the code
from __future__ import annotations

from dataclasses import dataclass
from collections.abc import Iterator


@dataclass
class Position:
    symbol: str
    value_usd: float

The Initial Portfolio Class

We start with a standard class definition. To index our strategy efficiently, we’ll store the positions in a dictionary keyed by their symbol. We name it _contents to signal it’s an internal implementation detail—a trade secret, if you will—encouraging users to access data through the public interface.

Show the code
class Portfolio:
    def __init__(
        self,
        name: str,
        managers: tuple[str, ...],
        contents: list[Position] | None = None,
    ):
        self.name = name
        self.managers = managers
        # Store positions in a dictionary keyed by symbol for fast lookup
        self._contents = (
            {pos.symbol: pos for pos in contents} if contents else {}
        )

    def __repr__(self):
        repr_contents = list(self._contents.values())
        return (
            f"Portfolio(name={self.name!r}, managers={self.managers!r}, "
            f"contents={repr_contents!r})"
        )


# Create some data
pos1 = Position("AAPL", 15000.0)
pos2 = Position("GOOG", 140000.0)
p = Portfolio("Tech Fund", ("Alice", "Bob"), [pos1, pos2])
print(p)
Portfolio(name='Tech Fund', managers=('Alice', 'Bob'), contents=[Position(symbol='AAPL', value_usd=15000.0), Position(symbol='GOOG', value_usd=140000.0)])

We included __repr__ right away. This dunder method provides the “official” string representation, useful for debugging the object’s state.

Emulating a Collection

Right now, if we try to gauge the size of our portfolio, it raises a TypeError.

Show the code
try:
    len(p)
except TypeError as e:
    print(f"Error: {e}")
Error: object of type 'Portfolio' has no len()

To enable the len() function, we implement __len__.

__len__

Show the code
# Inheriting to add methods incrementally for this post
class Portfolio(Portfolio):
    def __len__(self):
        return len(self._contents)


p = Portfolio("Tech Fund", ("Alice", "Bob"), [pos1, pos2])
print(f"Portfolio size: {len(p)}")
Portfolio size: 2

Now len(p) delegates to the underlying self._contents dictionary.

__getitem__

Instead of writing a custom get_position() helper, __getitem__ allows instances to support Python’s standard square bracket indexing: portfolio["AAPL"].

Show the code
class Portfolio(Portfolio):
    def __getitem__(self, symbol: str) -> Position:
        try:
            return self._contents[symbol]
        except KeyError:
            raise KeyError(
                f"Position '{symbol}' not found in portfolio '{self.name}'"
            ) from None


p = Portfolio("Tech Fund", ("Alice", "Bob"), [pos1, pos2])
print(f"AAPL position: {p['AAPL']}")
AAPL position: Position(symbol='AAPL', value_usd=15000.0)

Note on Pythonic conventions: In Python, square-bracket lookups (obj[key]) are expected to raise KeyError when a key does not exist. Returning None silently hides bugs and causes confusing AttributeError failures downstream. If callers need a safe fallback without exceptions, provide an explicit .get() method.

Enabling Iteration: __iter__

Iterating through a collection is fundamental. Since we backed our portfolio with a dictionary, default iteration would just give us the keys (symbols). But when we loop through a portfolio, we usually want the assets themselves. We can dictate this behavior by implementing __iter__.

Show the code
class Portfolio(Portfolio):
    def __iter__(self) -> Iterator[Position]:
        return iter(self._contents.values())


p = Portfolio("Tech Fund", ("Alice", "Bob"), [pos1, pos2])

print("Iterating through portfolio:")
for pos in p:
    print(f" - {pos.symbol}: ${pos.value_usd:,.2f}")
Iterating through portfolio:
 - AAPL: $15,000.00
 - GOOG: $140,000.00

Now, for pos in p yields the positions directly. We have full control over the traversal.

Membership and Truthiness: __contains__ and __bool__

We can already check membership via __iter__, but that’s O(N). Since we have a dictionary underneath, __contains__ gives us O(1) lookup.

We also want to know if our portfolio is empty. By default, Python checks len(), but implementing __bool__ allows us to be explicit about what constitutes a “truthy” portfolio.

Show the code
class Portfolio(Portfolio):
    def __contains__(self, item: str | Position) -> bool:
        if isinstance(item, str):
            return item in self._contents
        if isinstance(item, Position):
            return item.symbol in self._contents
        return False

    def __bool__(self) -> bool:
        return bool(self._contents)


p = Portfolio("Tech Fund", ("Alice", "Bob"), [pos1, pos2])

print(f"Is 'AAPL' in p? {'AAPL' in p}")
print(f"Is 'MSFT' in p? {'MSFT' in p}")
print(f"Is p truthy? {bool(p)}")

empty_p = Portfolio("Empty", ("Nobody",))
print(f"Is empty_p truthy? {bool(empty_p)}")
Is 'AAPL' in p? True
Is 'MSFT' in p? False
Is p truthy? True
Is empty_p truthy? False

Operator Overloading: __add__ (Combining Portfolios)

In Python, objects can be combined using operators. If we have two portfolios, it makes intuitive sense to “add” them together using +.

We implement this via __add__. This isn’t just concatenation; it’s a consolidation. We need to combine managers and sum up the value of overlapping positions.

Show the code
class Portfolio(Portfolio):
    def __add__(self, other: Portfolio) -> Portfolio:
        if not isinstance(other, Portfolio):
            return NotImplemented

        new_name = f"{self.name} + {other.name}"
        # Combine managers, removing duplicates
        new_managers = tuple(sorted(set(self.managers + other.managers)))
        # Merge contents
        all_positions = {}

        # Add positions from self
        for pos in self:
            all_positions[pos.symbol] = Position(pos.symbol, pos.value_usd)

        # Add positions from other
        for pos in other:
            if pos.symbol in all_positions:
                existing = all_positions[pos.symbol]
                existing.value_usd += pos.value_usd
            else:
                all_positions[pos.symbol] = Position(pos.symbol, pos.value_usd)
        return Portfolio(new_name, new_managers, list(all_positions.values()))


# Example Usage
p1 = Portfolio("Tech", ("Alice",), [Position("AAPL", 15000.0)])
p2 = Portfolio(
    "Growth", ("Bob",), [Position("AAPL", 8000.0), Position("MSFT", 30000.0)]
)

p3 = p1 + p2
print(f"Merged Portfolio: {p3}")
print(f"Merged AAPL: {p3['AAPL']}")
Merged Portfolio: Portfolio(name='Tech + Growth', managers=('Alice', 'Bob'), contents=[Position(symbol='AAPL', value_usd=23000.0), Position(symbol='MSFT', value_usd=30000.0)])
Merged AAPL: Position(symbol='AAPL', value_usd=23000.0)

String Representation: __repr__ Vs __str__

Finally, we need to decide how our portfolio presents itself to the world.

  • __repr__ (The Private Ledger): Unambiguous, developer-focused. It should ideally allow you to recreate the object.
  • __str__ (The Public Face): Readable, user-focused. “What does this object represent to a human?”

If __str__ is not defined, Python falls back to __repr__. But we want a nice summary.

Show the code
class Portfolio(Portfolio):
    def __str__(self):
        managers = ", ".join(self.managers)
        return f"Portfolio '{self.name}' ({len(self)} items, {managers})"


p = Portfolio("Tech Fund", ("Alice", "Bob"), [pos1, pos2])
print(f"Str: {str(p)}")
print(f"Repr: {repr(p)}")
Str: Portfolio 'Tech Fund' (2 items, Alice, Bob)
Repr: Portfolio(name='Tech Fund', managers=('Alice', 'Bob'), contents=[Position(symbol='AAPL', value_usd=15000.0), Position(symbol='GOOG', value_usd=140000.0)])

Architectural Takeaway

Implementing special methods transforms bespoke data structures into first-class citizens in Python’s runtime. Standard operators (len, in, +, []) behave predictably, allowing your domain classes to integrate naturally with standard library algorithms, debugging tools, and serialization pipelines.

In the next post, we explore inheritance, composition, and protocols to model financial yield curves.

Note
Back to top