Magic Methods & the Python Data Model

Reviewed & published by Brayan K

Master Python's powerful magic methods (dunder methods) and understand how the Python data model works under the hood. Learn to create custom classes that behave like built-in types, implement operator overloading, and build professional-grade objects.

Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

🔥 1. What Are Magic Methods?

Magic methods are methods Python calls automatically when certain operations happen.

OperationMagic Method
len(obj)__len__
obj + other__add__
obj[i]__getitem__
for x in obj__iter__
str(obj)__str__
with obj:__enter__, __exit__
obj()__call__

They let you define how your objects behave in every situation.

⚙️ 2. Object Construction (Creation & Initialization)

class User:
    def __new__(cls, *args, **kwargs):
        print("Allocating memory...")
        return super().__new__(cls)

    def __init__(self, name):
        print("Initializing...")
        self.name = name

# Create a user
user = User("Alice")
print(f"User name: {user.name}")

# ✅ Expected output:
# Allocating memory...
# Initializing...
# User name: Alice

🧱 3. Representation Methods

These control how an object looks when printed or displayed.

__repr__ — official representation (for developers)

class User:
    def __init__(self, name):
        self.name = name
    
    def __repr__(self):
        return f"User(name={self.name!r})"

user = User("Alice")
print(repr(user))  # User(name='Alice')

# ✅ Expected output:
# User(name='Alice')
class User:
    def __init__(self, name):
        self.name = name
    
    def __str__(self):
        return self.name

user = User("Alice")
print(str(user))  # Alice
print(user)       # Alice

# ✅ Expected output:
# Alice
# Alice

Used for f-strings, formatting currencies, dates, metrics.

🔢 4. Numeric Magic Methods (Act Like Numbers!)

Implementing these turns objects into custom numeric types:

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)
    
    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

v1 = Vector(1, 2)
v2 = Vector(3, 4)
v3 = v1 + v2
print(v3)  # Vector(4, 6)

# ✅ Expected output:
# Vector(4, 6)

Also available: Subtraction, Multiplication, Division, Modulo, Power, Negation

🧠 5. Comparisons (Sorting, Ordering, Equality)

class Player:
    def __init__(self, name, score):
        self.name = name
        self.score = score
    
    def __lt__(self, other):
        return self.score < other.score
    
    def __repr__(self):
        return f"{self.name}: {self.score}"

players = [Player("Alice", 100), Player("Bob", 150), Player("Charlie", 75)]
sorted_players = sorted(players)
for p in sorted_players:
    print(p)

# ✅ Expected output:
# Charlie: 75
# Alice: 100
# Bob: 150

📦 6. Container Protocol (Behaving Like Lists & Dicts)

Controls what len(obj) returns.

class MyList:
    def __init__(self, data):
        self.data = data
    
    def __len__(self):
        return len(self.data)
    
    def __getitem__(self, index):
        return self.data[index]

my_list = MyList([1, 2, 3, 4, 5])
print(f"Length: {len(my_list)}")
print(f"First item: {my_list[0]}")
print(f"Slice: {my_list[1:3]}")

# ✅ Expected output:
# Length: 5
# First item: 1
# Slice: [2, 3]

🔄 7. Iterable Protocol (for...in Loops)

To make an object iterable:

class Countdown:
    def __init__(self, start):
        self.start = start
    
    def __iter__(self):
        self._current = self.start
        return self
    
    def __next__(self):
        if self._current <= 0:
            raise StopIteration
        self._current -= 1
        return self._current + 1

for num in Countdown(5):
    print(num)

# ✅ Expected output:
# 5
# 4
# 3
# 2
# 1

🧪 Worked Example — One Class, Seven Magic Methods

You have met the pieces separately. Here they are working together on one small class, so you can see what "the data model" actually buys you: a Playlist that you can print, measure with len(), index, slice, search with in, loop over, and join with + — using nothing but ordinary Python syntax.

Read the comments before you run it. Each one names the method Python calls and what it hands back.

# A Playlist that behaves like a built-in Python container.
# Every method below is a hook: you never call it yourself —
# Python calls it for you when you use the matching syntax.

class Playlist:
    def __init__(self, name, tracks):
        # Runs straight after the object is created. Stores data, returns nothing.
        self.name = name
        self.tracks = list(tracks)   # copy the list so the caller can't mutate ours

    def __repr__(self):
        # The DEVELOPER view. Aim for text you could paste back into Python.
        # !r applies repr() to the value, so strings keep their quotes.
        return f"Playlist(name={self.name!r}, tracks={self.tracks!r})"

    def __str__(self):
        # The USER view — what print() shows. Leave it out and print() falls
        # back to __repr__, which is why __repr__ is the one you always write.
        return f"{self.name} ({len(self.tracks)} tracks)"

    def __len__(self):
        # Called by len(playlist). Must return a non-negative whole number.
        return len(self.tracks)

    def __getitem__(self, index):
        # Called by playlist[0] AND by playlist[0:2] (index is then a slice).
        # Bonus: because this exists, `for track in playlist` works too.
        return self.tracks[index]

    def __contains__(self, track):
        # Called by `track in playlist`.
        return track in self.tracks

    def __add__(self, other):
        # Called by playlist_a + playlist_b. Return a NEW object — never edit
        # self here, or `a + b` would secretly change a.
        return Playlist(f"{self.name} + {other.name}", self.tracks + other.tracks)


chill = Playlist("Chill", ["Weightless", "Nightswimming"])
focus = Playlist("Focus", ["Clair de Lune"])

print(str(chill))                 # __str__
print(repr(chill))                # __repr__
print(len(chill))                 # __len__
print(chill[0])                   # __getitem__ with an int
print(chill[0:2])                 # __getitem__ with a slice -> a plain list
print("Clair de Lune" in chill)   # __contains__
for track in focus:               # iteration, powered by __getitem__
    print("-", track)
both = chill + focus              # __add__
print(both)                       # __str__ of the brand-new object

# ✅ Expected output:
# Chill (2 tracks)
# Playlist(name='Chill', tracks=['Weightless', 'Nightswimming'])
# 2
# Weightless
# ['Weightless', 'Nightswimming']
# False
# - Clair de Lune
# Chill + Focus (3 tracks)

Two details worth noticing. chill[0:2] returns a plain list, not a Playlist — your __getitem__ passed the slice straight to self.tracks, so you get whatever a list gives back. And "Clair de Lune" in chill is False because that track lives in focus, not chill.

🎯 Your Turn — Make a Basket Act Like a Container

Everything except the three magic method bodies is written for you. Fill in the blanks marked ___, run it, and compare against the expected output at the bottom of the file.

Hint for the third blank: inside the class, len(self) calls your own __len__ — you do not need to reach for self.items again.

# 🎯 YOUR TURN — a shopping Basket that behaves like a real container
# Fill in the three blanks marked ___

class Basket:
    def __init__(self, items):
        self.items = list(items)      # already written for you

    def __len__(self):
        # 👉 replace ___ so that len(basket) reports how many items are inside
        return ___

    def __getitem__(self, index):
        # 👉 replace ___ so that basket[1] returns the second item
        return ___

    def __str__(self):
        # 👉 replace ___ with the item count (len(self) calls your __len__)
        return f"Basket of ___ items"


basket = Basket(["apple", "bread", "milk"])

print(len(basket))     # uses __len__
print(basket[1])       # uses __getitem__
print(basket)          # uses __str__
for item in basket:    # looping falls back to __getitem__
    print("-", item)

# ✅ Expected output:
# 3
# bread
# Basket of 3 items
# - apple
# - bread
# - milk

🧩 8. Callable Objects (Pretend to Be Functions)

class Adder:
    def __init__(self, n):
        self.n = n

    def __call__(self, x):
        return x + self.n

# Now:
add5 = Adder(5)
print(add5(10))   # 15
print(add5(20))   # 25

# ✅ Expected output:
# 15
# 25

🔐 9. Attribute Access Control

class FlexibleObject:
    def __init__(self):
        self.data = {}
    
    def __getattr__(self, name):
        return f"{name} is not defined"

obj = FlexibleObject()
print(obj.name)     # name is not defined
print(obj.anything) # anything is not defined

# ✅ Expected output:
# name is not defined
# anything is not defined

🧱 10. Boolean Value

class Container:
    def __init__(self, items):
        self.items = items
    
    def __bool__(self):
        return len(self.items) > 0

empty = Container([])
full = Container([1, 2, 3])

if full:
    print("Container has items")
if not empty:
    print("Container is empty")

# ✅ Expected output:
# Container has items
# Container is empty

Now objects behave logically in:

🧊 11. Context Managers

class Timer:
    def __enter__(self):
        import time
        self.start = time.time()
        print("Starting timer...")
        return self

    def __exit__(self, exc_type, exc, tb):
        import time
        elapsed = time.time() - self.start
        print(f"Elapsed: {elapsed:.4f} seconds")

with Timer():
    # Some operation
    total = sum(range(1000000))
    print(f"Sum: {total}")

🧱 12. Copy & Serialization Hooks

🔥 13. Operator Overloading (Make Objects Behave Like Built-ins)

Python lets you define how your objects react to operators.

class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)
    
    def __sub__(self, other):
        return Vector(self.x - other.x, self.y - other.y)
    
    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

v1 = Vector(3, 4)
v2 = Vector(1, 2)
print(f"v1 + v2 = {v1 + v2}")
print(f"v1 - v2 = {v1 - v2}")
print(f"v1 * 2 = {v1 * 2}")

# ✅ Expected output:
# v1 + v2 = Vector(4, 6)
# v1 - v2 = Vector(2, 2)
# v1 * 2 = Vector(6, 8)

Operator overloading is used everywhere:

🧱 14. Rich Comparisons — Smarter Sorting & Ranking

If you must support ordering, implement the following:

Example: sortable players by score

class Player:
    def __init__(self, name, score):
        self.name = name
        self.score = score
    
    def __lt__(self, other):
        return self.score < other.score
    
    def __eq__(self, other):
        return self.score == other.score
    
    def __repr__(self):
        return f"{self.name}({self.score})"

p1 = Player("Alice", 100)
p2 = Player("Bob", 150)
print(f"{p1} < {p2}: {p1 < p2}")
print(f"{p1} == {p2}: {p1 == p2}")

# ✅ Expected output:
# Alice(100) < Bob(150): True
# Alice(100) == Bob(150): False

🧩 15. Sequence Protocol (Full Custom List Behavior)

To behave like a real sequence, implement:

class ReadOnlySeq:
    def __init__(self, data):
        self._data = tuple(data)

    def __len__(self):
        return len(self._data)

    def __getitem__(self, i):
        return self._data[i]
    
    def __contains__(self, item):
        return item in self._data

seq = ReadOnlySeq([1, 2, 3, 4, 5])
print(f"Length: {len(seq)}")
print(f"First: {seq[0]}")
print(f"3 in seq: {3 in seq}")

# ✅ Expected output:
# Length: 5
# First: 1
# 3 in seq: True

🧠 16. Iterable vs Iterator (Clear Distinction)

Has __iter__(), returns an iterator.

Has __iter__() and __next__().

🧱 17. Custom Iterators (Full Control of Data Streams)

class Countdown:
    def __init__(self, n):
        self.n = n

    def __iter__(self):
        return self

    def __next__(self):
        if self.n <= 0:
            raise StopIteration
        self.n -= 1
        return self.n + 1

for num in Countdown(5):
    print(num)

# ✅ Expected output:
# 5
# 4
# 3
# 2
# 1

🔐 18. Attribute Access Magic

Python gives complete control over attribute access.

class DynamicObject:
    def __getattr__(self, name):
        return f"{name} does not exist"

obj = DynamicObject()
print(obj.foo)      # foo does not exist
print(obj.bar)      # bar does not exist

# ✅ Expected output:
# foo does not exist
# bar does not exist

🧩 19. Emulating Functions With __call__

Anything can behave like a function.

class Multiply:
    def __init__(self, factor):
        self.factor = factor

    def __call__(self, x):
        return x * self.factor

# Usage:
double = Multiply(2)
triple = Multiply(3)

print(double(10))   # 20
print(triple(10))   # 30

# ✅ Expected output:
# 20
# 30

🧱 20. Context Manager Magic

class DatabaseConnection:
    def __enter__(self):
        print("Opening connection...")
        return self
    
    def __exit__(self, exc, val, tb):
        print("Closing connection...")
    
    def query(self, sql):
        print(f"Executing: {sql}")

with DatabaseConnection() as db:
    db.query("SELECT * FROM users")

# ✅ Expected output:
# Opening connection...
# Executing: SELECT * FROM users
# Closing connection...

🔥 21. Descriptors — The Hidden Power Behind @property

Descriptors define how attributes behave.

A descriptor is any object defining one or more of:

class Logged:
    def __set_name__(self, owner, name):
        self.name = name
    
    def __get__(self, instance, owner):
        if instance is None:
            return self
        print(f"Accessed {self.name}")
        return instance.__dict__.get(self.name)

    def __set__(self, instance, value):
        print(f"Setting {self.name} to {value}")
        instance.__dict__[self.name] = value

class User:
    name = Logged()

u = User()
u.name = "Alice"
print(u.name)

# ✅ Expected output:
# Setting name to Alice
# Accessed name
# Alice

🧬 22. Properties Built on Top of Descriptors

property is just a wrapper around descriptors.

class User:
    def __init__(self, name):
        self._name = name
    
    @property
    def name(self):
        return self._name
    
    @name.setter
    def name(self, value):
        if not value:
            raise ValueError("Name cannot be empty")
        self._name = value

u = User("Alice")
print(u.name)
u.name = "Bob"
print(u.name)

# ✅ Expected output:
# Alice
# Bob

⚡ 23. Slots — Memory-Efficient Objects

Use __slots__ to avoid dynamic dictionaries:

class Point:
    __slots__ = ("x", "y")
    
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
print(f"Point: ({p.x}, {p.y})")

# This would raise AttributeError:
# p.z = 3  # Can't add new attributes!

# ✅ Expected output:
# Point: (1, 2)

⚡ 24. Understanding the Python Object Lifecycle

Every Python object goes through:

class Singleton:
    _instance = None

    def __new__(cls):
        if not cls._instance:
            print("Creating new instance")
            cls._instance = super().__new__(cls)
        else:
            print("Returning existing instance")
        return cls._instance

s1 = Singleton()
s2 = Singleton()
print(f"Same object: {s1 is s2}")

# ✅ Expected output:
# Creating new instance
# Returning existing instance
# Same object: True

🔥 25. Metaclasses — The Most Advanced Python Feature

A metaclass is the class of a class.

Classes create objects. Metaclasses create classes.

class Meta(type):
    def __new__(mcls, name, bases, attrs):
        print(f"Creating class {name}")
        return super().__new__(mcls, name, bases, attrs)

# Usage:
class User(metaclass=Meta):
    pass

class Admin(User):
    pass

# ✅ Expected output:
# Creating class User
# Creating class Admin

What metaclasses are used for IN REAL SYSTEMS

🧩 26. Class Decorators vs Metaclasses

Class decorators modify the class after it's created:

registry = []

def register(cls):
    registry.append(cls)
    return cls

@register
class User:
    pass

@register
class Admin:
    pass

print(f"Registered classes: {registry}")

# ✅ Expected output:
# Registered classes: [<class '__main__.User'>, <class '__main__.Admin'>]

Metaclasses modify the class while being created.

When to use what:

🧠 27. Emulating Containers (Full Custom Collections)

class BoundedList:
    def __init__(self, limit):
        self.data = []
        self.limit = limit

    def __len__(self):
        return len(self.data)

    def __getitem__(self, i):
        return self.data[i]

    def append(self, val):
        if len(self.data) >= self.limit:
            raise ValueError("List full")
        self.data.append(val)

    def __repr__(self):
        return f"BoundedList({self.data})"

bl = BoundedList(3)
bl.append(1)
bl.append(2)
bl.append(3)
print(bl)
# bl.append(4)  # Would raise ValueError

# ✅ Expected output:
# BoundedList([1, 2, 3])

🧱 28. Making Your Objects Hashable

For objects to be used as dictionary keys OR in sets:

class Point:
    __slots__ = ("x", "y")
    
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __hash__(self):
        return hash((self.x, self.y))

    def __eq__(self, other):
        return self.x == other.x and self.y == other.y
    
    def __repr__(self):
        return f"Point({self.x}, {self.y})"

# Now can use as dict keys
points = {Point(0, 0): "origin", Point(1, 1): "diagonal"}
print(points[Point(0, 0)])

# ✅ Expected output:
# origin

🔍 29. Overriding Truthiness & Boolean Behavior

class Connection:
    def __init__(self, status):
        self.status = status
    
    def __bool__(self):
        return self.status == "ready"

conn1 = Connection("ready")
conn2 = Connection("disconnected")

if conn1:
    print("Connection 1 is ready")
if not conn2:
    print("Connection 2 is not ready")

# ✅ Expected output:
# Connection 1 is ready
# Connection 2 is not ready

🧬 30. Controlling String Representations

class User:
    def __init__(self, id, name):
        self.id = id
        self.name = name
    
    def __repr__(self):
        return f"User(id={self.id})"

    def __str__(self):
        return self.name

u = User(1, "Alice")
print(repr(u))  # User(id=1)
print(str(u))   # Alice
print(u)        # Alice (uses __str__)

# ✅ Expected output:
# User(id=1)
# Alice
# Alice

⚙️ 31-34. Advanced Topics

Additional advanced magic method patterns:

class Money:
    def __init__(self, amount):
        self.amount = amount

    def __add__(self, other):
        return Money(self.amount + other.amount)

    def __mul__(self, rate):
        return Money(self.amount * rate)

    def __repr__(self):
        return "Money(" + str(self.amount) + ")"

m1 = Money(100)
m2 = Money(50)
print("m1 + m2 =", m1 + m2)
print("m1 * 1.5 =", m1 * 1.5)

# ✅ Expected output:
# m1 + m2 = Money(150)
# m1 * 1.5 = Money(150.0)

🎓 35. Final Summary — You Now Understand the Entire Python Data Model

By mastering this lesson, you now understand:

You now write Python the way framework authors, not beginners, write it.

This knowledge places you firmly at the top 1% of Python engineers.

📋 Quick Reference — Magic Methods

MethodTriggered by
__init__(self)Object creation: MyClass()
__repr__(self)repr() and interactive shell display
__len__(self)len(obj)
__getitem__(self, key)obj[key] indexing
__enter__ / __exit__with obj: context manager

🎯 Mini-Challenge: A Temperature Type That Sorts Itself

No filled-in logic this time — only an outline. Write a Temperature class that stores a reading in degrees Celsius, prints as Temperature(21.5), treats two readings of the same value as equal, and can be handed straight to sorted().

One thing that surprises people: sorted() only needs __lt__. It never calls __gt__ — it works out the whole ordering from "less than" alone.

# 🎯 MINI-CHALLENGE: a Temperature type that sorts itself
#
# 1. class Temperature with __init__(self, celsius) storing self.celsius
# 2. __repr__ returning text like  Temperature(21.5)
# 3. __eq__  — True when the two readings hold the same celsius value
# 4. __lt__  — True when self.celsius is lower than other.celsius
# 5. Then run these three lines:
#       readings = [Temperature(21.5), Temperature(-3), Temperature(8)]
#       print(sorted(readings))
#       print(Temperature(8) == Temperature(8))
#
# ✅ Expected output:
# [Temperature(-3), Temperature(8), Temperature(21.5)]
# True

# your code here

Stuck on __repr__? It must return a string, not print one — returning None by accident is the most common slip here.

🎉 Great work! You've completed this lesson.

You now control how your objects respond to built-in Python operations — the same protocol powering NumPy, pandas, and SQLAlchemy.

Practice quiz

Which magic method is called when you do len(obj)?

  • __size__
  • __count__
  • __len__
  • __length__

Answer: __len__. Python calls __len__ to implement len(obj).

Which magic method implements obj + other?

  • __add__
  • __plus__
  • __sum__
  • __concat__

Answer: __add__. __add__ defines behavior for the + operator.

What does __repr__ provide?

  • A user-friendly display for end users
  • The length of the object
  • A boolean value
  • An official, developer-facing representation of the object

Answer: An official, developer-facing representation of the object. __repr__ is the official representation aimed at developers; __str__ is the user-friendly one.

Which two magic methods make an object work in a for...in loop as its own iterator?

  • __loop__ and __step__
  • __iter__ and __next__
  • __getitem__ and __len__
  • __enter__ and __exit__

Answer: __iter__ and __next__. An iterator implements __iter__ (returns self) and __next__ (yields values, raising StopIteration to end).

Which magic method lets an instance be called like a function, e.g. obj(10)?

  • __call__
  • __invoke__
  • __run__
  • __exec__

Answer: __call__. __call__ makes an instance callable, so obj(10) runs obj.__call__(10).

Which pair of magic methods implements the 'with obj:' context manager protocol?

  • __open__ and __close__
  • __start__ and __stop__
  • __enter__ and __exit__
  • __begin__ and __end__

Answer: __enter__ and __exit__. Context managers implement __enter__ (on entry) and __exit__ (on exit).

Which magic method controls the result of len() AND is part of the sequence protocol alongside __getitem__?

  • __count__
  • __len__
  • __items__
  • __size__

Answer: __len__. __len__ controls len(obj) and, with __getitem__, forms the core sequence protocol.

To use your objects as dictionary keys or in sets, which methods must you implement?

  • __key__ and __set__
  • __dict__ and __getitem__
  • __index__ and __len__
  • __hash__ and __eq__

Answer: __hash__ and __eq__. Hashable objects need __hash__ and a matching __eq__ so they behave correctly in dicts and sets.

Which method is the fallback called only when a normal attribute lookup fails?

  • __getattribute__
  • __getattr__
  • __getitem__
  • __get__

Answer: __getattr__. __getattr__ is the fallback for missing attributes; __getattribute__ intercepts EVERY attribute access.

What is a metaclass?

  • A class with only static methods
  • A class that cannot be instantiated
  • The class of a class — it creates classes (default is type)
  • A copy of a class

Answer: The class of a class — it creates classes (default is type). A metaclass is the class of a class; classes create objects, metaclasses create classes (default metaclass is type).

Continue this course