Introduction
Welcome back! In our last lesson, you learned how to implement the __len__ and __contains__ methods, making your custom objects compatible with Python's built-in len() function and the in operator. This was a key step in making your classes behave like native Python containers.
Today, we'll continue down that path to tackle one of the most common and intuitive features of Python sequences: accessing elements with square brackets. This lesson focuses on the __getitem__ dunder method. By implementing it, you'll enable integer-based indexing on your custom objects, so that my_object[0] works exactly as you'd expect from a list or tuple. This is the Pythonic replacement for the list.get(index) pattern you might be used to from Java, offering more direct and readable syntax.
Enabling Indexing with __getitem__
In Python, the square bracket notation for accessing items is not a hard-coded feature limited to built-in types. It's a protocol, and any class can adopt it by implementing the __getitem__ dunder method.
When Python encounters an expression like my_object[key], it translates this into a method call: my_object.__getitem__(key). The value inside the brackets (key) is passed as an argument to the method.

For today's lesson, we will focus on the key being an integer index.
Let's see this in action with a short video clip.
Please Master This MAGIC Python Feature... 🪄
This clip from the Tech With Tim video you've seen before will show you exactly how the indexing syntax [] maps to the __getitem__ method.
Watch from 16:33 to 17:48. Pay attention to how the __getitem__ method is defined in the LinkedList class and how it's called implicitly when using square brackets on an instance of the class.
As the video demonstrates, implementing __getitem__ is the hook that allows your custom object to participate in Python's indexing syntax.
A Practical Implementation
Let's continue with the Sentence class from our previous lessons. We've already taught it how to be iterated over, how to report its length, and how to check for membership. Now, let's teach it how to fetch a word by its index.
class Sentence:
def __init__(self, text: str):
self.words = text.split()
self._word_set = set(self.words)
def __iter__(self):
for word in self.words:
yield word
def __len__(self):
return len(self.words)
def __contains__(self, word: str):
return word.lower() in self._word_set
# Implementing __getitem__
def __getitem__(self, index: int):
"""Returns the word at the given integer index."""
return self.words[index]
# --- Usage ---
s = Sentence("The quick brown fox jumps over the lazy dog")
print(f"First word: {s[0]}") # Calls s.__getitem__(0)
print(f"Fifth word: {s[4]}") # Calls s.__getitem__(4)
print(f"Last word: {s[-1]}") # Calls s.__getitem__(-1), negative indexing works!
# This will raise an IndexError, just like a list would.
# print(s[100])
Notice the simplicity here. We are delegating the indexing operation to the internal self.words list. This is a common and powerful pattern. Because Python's lists already handle positive and negative indexing, as well as raising an IndexError for out-of-bounds access, we get all that correct behavior for free.
For another clear example, let's look at a different implementation.
Making the Class Indexable Using .__getitem__()
This Real Python article demonstrates how to make a ShapePoints class indexable by adding a __getitem__ method that delegates to an internal list. This reinforces the pattern we just used.
Read this section to see another straightforward example of implementing __getitem__. Note how adding this one method also implicitly enables slicing and iteration, a topic we will explore next.
The Sequence Protocol: A Powerful Side Effect
As the Real Python article hinted, something remarkable happens when a class implements both __len__ and __getitem__. Even if you don't define __iter__, Python can still iterate over your object. This is known as the sequence protocol.
Here's how it works:
- When Python needs to iterate over an object (e.g., in a
forloop), it first looks for an__iter__method. If found, it uses that. - If
__iter__is not found, it looks for__getitem__and__len__. - If both are present, Python will effectively run a
whileloop, calling__getitem__with integer indices starting from0up tolen() - 1.
This fallback mechanism is a core part of Python's "batteries included" philosophy. By implementing a couple of fundamental methods, you get a whole range of behavior automatically.
The following reading explains this concept in detail.
How the presence of indexing impacts other operations
These notes from a university course provide an excellent, clear explanation of the sequence protocol and how __getitem__ and __len__ work together.
Read the section titled 'How the presence of indexing impacts other operations'. The key takeaway is understanding that Python has a fallback mechanism for iteration if __iter__ isn't defined but __getitem__ and __len__ are.
While this automatic behavior is useful, it's generally better to provide an explicit __iter__ method if you can, especially if it can be implemented more efficiently than repeated calls to __getitem__. Our Sentence class does this, which is the best practice.
Handling Invalid Indices
In our Sentence example, we delegated the index handling to a list, which automatically raises an IndexError for an invalid index. This is the correct, Pythonic behavior.
If you were to implement the indexing logic yourself, it would be your responsibility to raise the appropriate exceptions.
Consider this example from the UCI notes, which implements a custom range type.
Dunder methods for implementing indexing
Let's examine the MyRange example to see how to perform manual index validation.
Focus on the code block defining the __getitem__ method for the MyRange class. Notice the explicit checks for the index type and range. This is what you would need to do if you weren't delegating to a built-in container.
The code in the reading explicitly checks if the index is an integer and if it's within the valid bounds. If not, it raises TypeError or IndexError. This contrasts with a common pattern in other languages (like Java) of returning null or a special sentinel value. In Python, it's better to fail fast and loud with an exception.
Exercise: Implement Indexing for a ReadOnlyPortfolio
Now it's your turn. Your task is to implement the __getitem__ method for the ReadOnlyPortfolio class below. The class holds a list of stock tickers and should allow access to them by index.
# initial_code.py
class ReadOnlyPortfolio:
"""A read-only collection of stock tickers."""
def __init__(self, tickers: list[str]):
# A private copy of the list of tickers
self._tickers = list(tickers)
def __len__(self) -> int:
"""Returns the number of tickers in the portfolio."""
return len(self._tickers)
def __iter__(self):
"""Allows iteration over the tickers."""
return iter(self._tickers)
def __getitem__(self, index: int) -> str:
"""
TODO: Implement this method.
It should allow access to tickers by their integer index,
supporting both positive and negative indices.
"""
pass # Replace with your implementation
# --- Tests ---
portfolio = ReadOnlyPortfolio(["AAPL", "GOOG", "MSFT", "TSLA"])
# Test 1: __len__ and __iter__ should work out of the box
assert len(portfolio) == 4
assert list(portfolio) == ["AAPL", "GOOG", "MSFT", "TSLA"]
# Test 2: Positive indexing
assert portfolio[0] == "AAPL", "Expected 'AAPL' at index 0"
assert portfolio[2] == "MSFT", "Expected 'MSFT' at index 2"
# Test 3: Negative indexing
assert portfolio[-1] == "TSLA", "Expected 'TSLA' at index -1"
# Test 4: Check for correct exception on out-of-bounds access
try:
portfolio[10]
# If this line is reached, the test fails
assert False, "Expected an IndexError, but no exception was raised."
except IndexError:
# This is the expected outcome
pass
except Exception as e:
assert False, f"Expected an IndexError, but got {type(e).__name__}"
print("All tests passed!")
Click to see the solution
# solution_code.py
class ReadOnlyPortfolio:
"""A read-only collection of stock tickers."""
def __init__(self, tickers: list[str]):
# A private copy of the list of tickers
self._tickers = list(tickers)
def __len__(self) -> int:
"""Returns the number of tickers in the portfolio."""
return len(self._tickers)
def __iter__(self):
"""Allows iteration over the tickers."""
return iter(self._tickers)
def __getitem__(self, index: int) -> str:
"""
Allows access to tickers by their integer index,
supporting both positive and negative indices.
"""
return self._tickers[index]
# --- Tests ---
portfolio = ReadOnlyPortfolio(["AAPL", "GOOG", "MSFT", "TSLA"])
# Test 1: __len__ and __iter__ should work out of the box
assert len(portfolio) == 4
assert list(portfolio) == ["AAPL", "GOOG", "MSFT", "TSLA"]
# Test 2: Positive indexing
assert portfolio[0] == "AAPL", "Expected 'AAPL' at index 0"
assert portfolio[2] == "MSFT", "Expected 'MSFT' at index 2"
# Test 3: Negative indexing
assert portfolio[-1] == "TSLA", "Expected 'TSLA' at index -1"
# Test 4: Check for correct exception on out-of-bounds access
try:
portfolio[10]
# If this line is reached, the test fails
assert False, "Expected an IndexError, but no exception was raised."
except IndexError:
# This is the expected outcome
pass
except Exception as e:
assert False, f"Expected an IndexError, but got {type(e).__name__}"
print("All tests passed!")
Conclusion
By implementing __getitem__, you've unlocked a significant piece of Python's expressive power for your custom classes. Your objects now feel more deeply integrated with the language's syntax, making them more intuitive to use.
Key Takeaways:
- The square bracket syntax
obj[key]is syntactic sugar for the method callobj.__getitem__(key). - To support integer-based indexing, your
__getitem__method should handle integer arguments. - Delegating the operation to an internal list is a simple and robust way to implement
__getitem__, as you inherit correct behavior for negative indexing and exception handling. - The Pythonic way to handle invalid indices is to raise an
IndexError, not to return a special value likeNone. - An object with both
__len__and__getitem__automatically conforms to the sequence protocol, making it iterable as a fallback.
In the next lesson, we will extend what you've learned today. We'll see how the very same __getitem__ method is also used to handle slicing, such as my_object[1:5]. You'll learn how Python passes a slice object to your method and how to interpret it to return a sub-sequence of your container.