Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 13

Implementing Integer Indexing with `__getitem__`

Recommended resources

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.

Python `__getitem__` and `__setitem__` Implementation Example
This code shows a simple `MyList` class. The `__getitem__` method is what allows an instance of this class to respond to indexing operations like `my_list_instance[i]`.

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:

  1. When Python needs to iterate over an object (e.g., in a for loop), it first looks for an __iter__ method. If found, it uses that.
  2. If __iter__ is not found, it looks for __getitem__ and __len__.
  3. If both are present, Python will effectively run a while loop, calling __getitem__ with integer indices starting from 0 up to len() - 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 call obj.__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 like None.
  • 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.