Introduction

Welcome to The Python Data Model & Protocols! This is the first lesson in our "Python Power Tools: Exploring Advanced Language Features" learning path, and we're excited to begin this journey together as we explore Python's most powerful and elegant features. This learning path consists of five progressive courses that will transform you into an advanced Python practitioner:

  1. The Python Data Model & Protocols - Master Python's data model by implementing custom types that integrate seamlessly with Python's core features.
  2. Class Machinery: Dataclasses, Descriptors, Metaclasses - Build better classes using dataclasses, descriptors, and metaclasses for reusable, self-validating code.
  3. Functional Patterns & Pattern Matching - Write more expressive, declarative Python using functional programming patterns and modern control flow.
  4. Concurrency & Async I/O - Unlock concurrent programming in Python, mastering threads, processes, and asyncio for high-performance pipelines.
  5. Building an Async CLI Tool for ETL Pipelines in Python - Integrate all advanced Python features to build a complete, production-ready asynchronous ETL tool.

By the end of this path, you'll have the skills to write Python code that's not just functional, but genuinely Pythonic. You'll understand how to make your objects work seamlessly with built-in functions, operators, and language constructs. Most importantly, you'll have built a complete, professional-grade application that showcases these advanced techniques.

Note that we expect you to already be comfortable with Python fundamentals: basic syntax, classes, inheritance, and exception handling. You should understand how to define methods, work with properties, and feel confident reading and writing intermediate Python code. In case you don't feel comfortable with these topics, feel free to check out one of our beginner-level course paths!

Today, we're focusing on dunder semantics and value behavior. We'll learn how to implement the special methods that make custom objects feel like native Python types, starting with a robust Money class that handles representation, equality, ordering, and hashing correctly.

Understanding Python's Data Model

Python's elegance comes from its unified data model, where everything is an object that can participate in the language's core operations. When you write len(my_list) or str(my_object), Python isn't calling special-cased functions. Instead, it's invoking dunder methods (short for "double underscore", also called magic methods) that your objects can implement.

These dunder methods are Python's way of asking: "How should this object behave when someone calls len() on it? How should it represent itself as a string? How should it compare to other objects?" By implementing the right dunder methods, we can make our custom classes integrate seamlessly with Python's syntax and built-in functions.

The beauty of this system is that it creates a contract. When we implement __eq__, we're promising that our object knows how to test equality. When we implement __str__, we're providing a human-readable representation. This consistency is what makes Python code feel so natural and expressive.

The Foundation: Value Types and Money

To explore these concepts, we'll build a Money class that represents financial amounts with currency information. Money is an excellent example of a value type: an object whose identity is determined entirely by its data, not by where it lives in memory.

Consider two $10 bills. Even though they're physically different objects, we consider them equal because they represent the same value. This is exactly how our Money class should behave: two Money instances with the same amount and currency should be considered equal, even if they're separate objects in memory.

from decimal import Decimal, ROUND_HALF_EVEN
from functools import total_ordering
from typing import Any

def _to_decimal(value: Any) -> Decimal:
    """
    Convert common numeric inputs to Decimal safely.
    Uses str conversion to avoid float binary rounding artifacts.
    """
    if isinstance(value, Decimal):
        return value
    if isinstance(value, (int, str, float)):
        return Decimal(str(value))
    raise TypeError(f"Unsupported amount type: {type(value)!r}")

This helper function handles the tricky business of converting various numeric types to Decimal objects. We use Decimal for financial calculations because it provides exact decimal arithmetic, avoiding the rounding errors that plague floating-point numbers. Notice how we convert floats to strings first: this prevents binary floating-point approximations from contaminating our precise decimal calculations.

Building the Money Class Structure

Our Money class needs to store an amount and a currency, with proper validation for both:

class Money:
    """
    An immutable-ish value object representing an amount in a currency.
    - __repr__/__str__ give dev vs user-friendly displays
    - __eq__/__lt__ implement currency-aware comparisons
    - __hash__ allows usage in sets and dict keys (value semantics)
    """

    def __init__(self, amount: Any, currency: str) -> None:
        cur = str(currency).upper()
        if len(cur) != 3 or not cur.isalpha():
            raise ValueError("Currency must be a 3-letter code like 'USD'")
        dec = _to_decimal(amount)
        # Normalize to 2 decimal places (cents) using bankers rounding.
        q = Decimal("0.01")
        self._amount = dec.quantize(q, rounding=ROUND_HALF_EVEN)
        self._currency = cur

    @property
    def amount(self) -> Decimal:
        return self._amount

    @property
    def currency(self) -> str:
        return self._currency

The constructor does several important things: it validates that the currency is a three-letter alphabetic code, converts the amount to a Decimal, and normalizes it to exactly two decimal places using banker's rounding. This normalization ensures that Money(10) and Money(10.00) are truly identical, which will be crucial for our equality and hashing implementations.

Object Representation: Developer vs User Views

Every object needs two ways to represent itself as text. The __repr__ method provides an unambiguous, developer-focused representation that ideally could recreate the object. The __str__ method gives a clean, user-friendly display.

    def __repr__(self) -> str:
        return f"Money(Decimal('{self.amount}'), '{self.currency}')"

    def __str__(self) -> str:
        return f"{self.amount} {self.currency}"

The __repr__ method shows exactly how to construct an equivalent Money object, complete with the Decimal constructor call. This makes debugging much easier because you can see precisely what data an object contains. The __str__ method provides a clean format suitable for displaying to end users, focusing on readability over technical precision.

Let's see these representation methods in action:

m1 = Money("10.00", "usd")
print(repr(m1))  # Developer representation
print(str(m1))   # User-friendly representation
Money(Decimal('10.00'), 'USD')
10.00 USD

Notice how repr() gives us the full technical details while str() provides a clean, readable format. The currency "usd" was automatically normalized to uppercase "USD" during construction.

Value Equality: The __eq__ Method

For value types like Money, equality should be based on the actual values, not object identity. Two Money objects with the same amount and currency should be equal regardless of when or how they were created.

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Money):
            return NotImplemented
        return (self.currency, self.amount) == (other.currency, other.amount)

This implementation follows Python's best practices for equality comparison. When comparing to non-Money objects, we return NotImplemented rather than False. This allows Python's comparison machinery to try the other object's __eq__ method, enabling more sophisticated comparison protocols. For Money objects, we compare both currency and amount as a tuple, ensuring that 10 USD is not equal to 10 EUR.

Let's test equality with objects created in different ways:

m1 = Money("10.00", "usd")
m2 = Money(Decimal("10"), "USD")
print(m1 == m2)
True

Even though m1 was created with a string amount and lowercase currency while m2 used a Decimal and uppercase currency, they're equal because both normalize to the same internal representation: 10.00 USD.

Making Objects Hashable: The __hash__ Method

If objects can be equal, they need consistent hash values to work properly in sets and as dictionary keys. The hash function must guarantee that equal objects have equal hashes, though the reverse isn't required.

    def __hash__(self) -> int:
        # Hash on canonical "cents" to ensure 10.0 == 10.00 -> same hash
        cents = int((self.amount * Decimal("100")).to_integral_value())
        return hash((self.currency, cents))

We convert the amount to cents (an integer) before hashing to ensure that amounts like 10.00 and 10.0 produce identical hash values, since they're equal after our decimal normalization. We then hash the tuple of currency and cents, creating a robust hash function that respects our equality implementation.

Now that our Money objects are hashable, they can be used in sets and as dictionary keys:

m1 = Money("10.00", "usd")
m2 = Money(Decimal("10"), "USD")
m4 = Money("7.25", "USD")

# Money objects can be used in sets
s = {m1, m2, m4}
print(len(s))

# Money objects can be used as dictionary keys
d = {m1: "A", m4: "B"}
print(d[m2])
2
A

The set contains only 2 elements because m1 and m2 are equal and have the same hash, so they're deduplicated. Similarly, we can use m2 as a key to look up the value stored under m1 in the dictionary, since they're considered the same key.

Ordering with Rich Comparisons

The @total_ordering decorator is a powerful tool that generates all comparison methods from just __eq__ and one ordering method. We'll implement __lt__ (less than) and get >, <=, and >= automatically:

@total_ordering
class Money:
    [...]
    
    def __lt__(self, other: Any) -> bool:
        if not isinstance(other, Money):
            return NotImplemented
        if self.currency != other.currency:
            raise TypeError(
                f"Cannot order amounts in different currencies: "
                f"{self.currency} vs {other.currency}"
            )
        return self.amount < other.amount

This method enforces a crucial business rule: we can only compare money amounts in the same currency. Attempting to compare $10 USD with €8 EUR raises a TypeError with a clear explanation. This prevents subtle bugs where currency differences might be overlooked in financial calculations.

Total Ordering in Action

Let's see ordering in action with amounts in the same currency:

m1 = Money("10.00", "USD")
m4 = Money("7.25", "USD")
m5 = Money("12.00", "USD")

# Ordering within same currency
ordered = sorted([m5, m4, m1])
for x in ordered:
    print(str(x))
7.25 USD
10.00 USD
12.00 USD

The amounts are correctly sorted in ascending order. Now let's see what happens when we try to compare amounts in different currencies:

m1 = Money("10.00", "USD")
m3 = Money("9.50", "EUR")

try:
    _ = m1 < m3
except TypeError as e:
    print(str(e))
Cannot order amounts in different currencies: USD vs EUR

The comparison raises a clear error, preventing us from accidentally comparing incompatible currency amounts.

Conclusion and Next Steps

We've built a robust Money class that demonstrates the core principles of Python's data model. By implementing the right dunder methods, we've created objects that integrate seamlessly with Python's built-in functions and operators. Our Money instances can be compared, sorted, stored in sets, used as dictionary keys, and displayed meaningfully to both developers and users.

The patterns we've explored here — value semantics through __eq__ and __hash__, meaningful representations through __repr__ and __str__, and constrained ordering through __lt__ — form the foundation of well-behaved Python objects. These same principles apply whether you're building financial types, geometric objects, or any other value-oriented classes.

In the upcoming practice section, you'll apply these concepts hands-on, implementing similar dunder methods for different types and debugging common pitfalls that arise when these methods don't work together correctly. Happy coding!

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal