__getitem__ in Python: Building Indexable, Friendly, and Fast Objects

I still remember the first time a custom object surprised me by behaving like a list. I typed order[0] out of habit and it just worked. That tiny moment changed how I design Python APIs: if your objects feel natural to index, people trust them faster and make fewer mistakes. The magic behind that experience is getitem, the special method that powers square‑bracket access. When you implement it thoughtfully, your class can act like a list, a dict, a matrix, or even a data pipeline node. When you implement it poorly, you create confusion, hidden bugs, and slowdowns.

In this guide I’ll show you how getitem actually works, what kinds of keys it can receive, and how to design behavior that feels consistent and predictable. I’ll walk through practical patterns I use in production code, including slice support, tuple indexing, and error handling. I’ll also cover common mistakes, performance considerations, and when you should avoid getitem entirely. My goal is simple: help you build objects that are ergonomic, correct, and easy for others to understand.

Why getitem is the gateway to natural APIs

getitem is the method Python calls when you write obj[key]. That’s it. But the “key” can be far more than an integer. It can be a slice, a tuple, a string, or any custom object. This is the secret reason indexing feels so flexible across the standard library: lists accept ints and slices, dicts accept hashable keys, and NumPy arrays accept tuples, slices, and boolean masks.

I think about getitem as the doorway into your object. If the doorway is clear and well‑lit, your API feels intuitive. If you accept random keys and return unexpected types, you create friction. For example, if you build a “Row” object and allow row["email"] to return a string, you should also make row["missing"] raise KeyError, not IndexError. Consistency with built‑in containers is not just tradition; it’s how users predict behavior.

A simple analogy I use when teaching this: getitem is the front desk of a building. The sign on the building (your class name and docs) tells people what to ask for, and getitem determines how they get it. If you’re running a library, you don’t want people asking for coffee and getting a book. You want the request shape and the response shape to line up.

The call signature and key types you will actually see

The core signature is short:

def getitem(self, key):

In practice, the key you receive can be:

  • int: list‑style indexing
  • slice: start:stop:step
  • str: dict‑style or attribute‑style lookup
  • tuple: multi‑dimensional or mixed indexing
  • custom objects: query keys, sentinels, or domain types

I recommend handling only what you intend to support and failing loudly for everything else. It’s tempting to accept anything and “figure it out,” but that almost always creates silent bugs. If you’re building a time‑series wrapper, for example, support int and slice, maybe datetime keys, but not random tuples unless you explicitly document them.

Here’s a minimal list‑like wrapper that passes integers and slices through to an internal list. Notice that I keep error behavior aligned with list semantics by letting the underlying list raise IndexError.

class EventLog:

def init(self, events):

self._events = list(events)

def getitem(self, key):

return self._events[key]

log = EventLog(["start", "load", "save", "exit"])

print(log[1]) # load

print(log[1:3]) # [‘load‘, ‘save‘]

That’s enough for most wrappers. But as soon as you want custom behavior, you need a strategy.

Designing predictable behavior for slices, tuples, and mixed keys

The moment you accept more than a single index, you should be intentional about the shape of your input and output. I prefer these rules:

1) If the key is a slice, return the same kind of container you return for a list of items.

2) If the key is a tuple, interpret each part in a consistent order.

3) If the key is unsupported, raise TypeError with a clear message.

Here’s an example of a lightweight matrix class that supports m[row, col], plus slices for rows and columns. I use tuple keys to distinguish single values from range selections.

class Matrix:

def init(self, rows):

self._rows = [list(r) for r in rows]

def getitem(self, key):

if isinstance(key, tuple):

if len(key) != 2:

raise TypeError("Matrix expects two indices: [row, col]")

rowkey, colkey = key

rows = self.rows[rowkey]

# rows could be a list (single row) or list of rows (slice)

if isinstance(row_key, slice):

return [r[col_key] for r in rows]

return rows[col_key]

return self._rows[key]

m = Matrix([

[1, 2, 3],

[4, 5, 6],

[7, 8, 9],

])

print(m[1, 2]) # 6

print(m[0:2, 1]) # [2, 5]

print(m[0]) # [1, 2, 3]

I always test a few edge cases: negative indexes, out‑of‑range indexes, and slices with steps. Because I delegate to lists, slice behavior follows built‑in rules, which is exactly what you want. If you decide to deviate from built‑ins, make sure you have a really good reason and loud documentation.

A practical tip: avoid overloading the meaning of strings and tuples if you can. A row["total"] accessor is convenient, but it makes row[0] ambiguous in a way that can confuse both readers and type checkers. If you need both, document it clearly and use helper objects or enums for clarity.

When getitem should behave like a mapping

If your class is a mapping, it should act like a mapping. That means raising KeyError when a key is missing, supporting in membership via contains, and ideally providing .keys(), .values(), and .items() if it’s public API.

Here’s a small example for a configuration object that allows dictionary‑style access while doing type coercion. I keep the logic explicit because hidden conversions are another common source of bugs.

class Config:

def init(self, raw):

self._raw = dict(raw)

def getitem(self, key):

if key not in self._raw:

raise KeyError(f"Missing config key: {key}")

value = self._raw[key]

if key == "timeout_ms":

return int(value)

return value

cfg = Config({"timeout_ms": "1500", "env": "prod"})

print(cfg["timeout_ms"]) # 1500

I recommend raising KeyError even if you could return None. That keeps your object in line with dict behavior and makes bugs obvious. If you want a softer approach, provide a get() method, just like dict does. That’s a better pattern than making getitem permissive.

If your class is mapping‑like, consider collections.abc.Mapping as a base class. It documents expectations and can help static checkers. You still need to implement getitem, iter, and len, but it gives you a clear contract.

The relationship with setitem, iter, and len

I rarely implement getitem in isolation. Users expect a full “container experience,” and these dunder methods form a connected set:

  • getitem for obj[key]
  • setitem for obj[key] = value
  • delitem for del obj[key]
  • iter for iteration
  • len for len(obj)
  • contains for key in obj

When these methods are coherent, your class behaves like a first‑class container. When they are mismatched, you create surprise. A classic bug: getitem supports string keys but iter yields integer indexes. That leads to code like for k in obj: followed by obj[k] failing.

I usually design these methods together. If you want to expose a list of records, your iter should yield those records directly, not their keys. If you want mapping behavior, yield keys. That’s how built‑ins do it, and it makes mental models line up.

Here’s a small “indexable view” over a list of user records. Notice the consistent behavior across indexing, iteration, and length.

class UserList:

def init(self, users):

self._users = list(users)

def getitem(self, key):

return self._users[key]

def iter(self):

return iter(self._users)

def len(self):

return len(self._users)

users = UserList(["Ava", "Diego", "Rin"])

print(len(users))

for name in users:

print(name)

This is the “sequence” model. If you want a mapping model, switch to keys in iter and KeyError in getitem.

Real‑world patterns I use in production

I’ve used getitem in several recurring patterns. Here are the ones that keep showing up.

1) Lazy access for expensive data

If computing a value is expensive, getitem is a nice gateway for lazy evaluation. You can return cached results while keeping call sites clean.

class Metrics:

def init(self, source):

self._source = source

self._cache = {}

def getitem(self, key):

if key in self._cache:

return self._cache[key]

value = self.source.computemetric(key)

self._cache[key] = value

return value

The call site stays simple: metrics["cpu_load"]. In my experience, this improves readability without hiding complexity because the key name tells you you’re doing a lookup.

2) Views over structured data

When you have a list of rows but want dict‑style access, getitem can bridge the gap. I prefer an explicit row class rather than overloading a list.

class Row:

def init(self, columns, values):

self._columns = list(columns)

self._values = list(values)

def getitem(self, key):

if isinstance(key, int):

return self._values[key]

if isinstance(key, str):

try:

index = self._columns.index(key)

except ValueError:

raise KeyError(f"Unknown column: {key}")

return self._values[index]

raise TypeError("Row index must be int or str")

row = Row(["id", "email", "active"], [101, "[email protected]", True])

print(row[0]) # 101

print(row["email"]) # [email protected]

This is one of those cases where accepting two key types is reasonable because the domain strongly implies both.

3) Domain‑specific keys

Sometimes the key is not a primitive at all. For instance, in a feature flag system, you might pass a User object or a Region enum. getitem can become a friendly DSL for lookups.

class FlagStore:

def init(self, flags):

self._flags = dict(flags)

def getitem(self, key):

# key is a tuple: (flag_name, user)

if not isinstance(key, tuple) or len(key) != 2:

raise TypeError("Expected (flag_name, user)")

name, user = key

if name not in self._flags:

raise KeyError(f"Unknown flag: {name}")

return self.flags[name].isenabled(user)

This approach keeps call sites short and expressive, but you must document the tuple shape clearly.

Common mistakes I see (and how I avoid them)

Here are the issues I encounter most often when reviewing code that implements getitem:

1) Returning different types for similar keys

If obj[0] returns an item but obj[0:1] returns something unrelated, users will stumble. I keep the type for slices consistent with the type returned by list‑like access (often the same class or a list).

2) Catching all exceptions and returning None

This hides bugs and makes calling code behave unpredictably. If a key is missing, raise KeyError. If an index is out of range, allow IndexError. If the key type is wrong, raise TypeError.

3) Doing heavy work per call

getitem can be used in loops and tight paths. If you hit the network or do heavy computation each time, performance gets weird fast. Cache or precompute when possible.

4) Ignoring slice support for sequence‑like classes

If your class looks like a sequence and you don’t accept slices, you’ll surprise users. At minimum, accept slice and return a list or new instance.

5) Breaking iteration expectations

If you implement getitem but not iter, Python may still try to iterate by calling getitem with indexes starting at 0. This can lead to confusing errors if you don’t handle integer indexes properly.

I keep a simple checklist: handle slice, keep error types aligned with built‑ins, and make sure iteration behavior is sane.

When to use getitem, and when not to

I recommend getitem when the primary interaction is lookup or indexing. If your class models a collection, getitem usually makes sense. It’s also useful for “view” objects where you want clean, bracket‑based access to derived data.

I avoid getitem when:

  • The access pattern is not index‑like or key‑based. If you have a service object, prefer explicit methods like fetch_user().
  • The behavior is expensive and non‑obvious. Brackets imply quick, in‑memory access.
  • The key types are too broad. If users can pass almost anything, your API becomes hard to reason about and hard to type‑check.

If you want a command‑like operation, use methods. If you want lookup semantics, use getitem. That’s the line I draw in my own code.

Performance considerations without the hype

getitem is just a method call, but it can be invoked a lot. In tight loops, a naive implementation can show up in profiling. In my experience, lightweight indexing work is typically in the low single‑digit milliseconds per thousand lookups on modern machines, while expensive conversions or network calls can balloon into hundreds of milliseconds. That’s why I keep getitem tight and predictable.

Here are a few practical tips I actually use:

  • Avoid repeated linear searches. If you convert column names to indexes on every call (like list.index()), that’s O(n) each time. Cache a dict from name to index instead.
  • Return views when possible. If a slice returns a new list every time, memory usage can grow quickly. If it makes sense, return a lightweight view object that references the original data.
  • Keep the happy path fast. Check the most common key type first. If 95% of calls are integers, handle those before a chain of isinstance checks.
  • Do not hide I/O. If you must do I/O, expose a method so the call site reads as I/O. Brackets should feel like memory access.

If you need to support high‑volume access, I recommend a simple benchmark script and a basic profiler run. You don’t need deep tooling; a few looped lookups and time.perf_counter() can reveal slow spots quickly.

Traditional vs modern patterns for indexing APIs

As Python codebases evolve, I’ve noticed two competing styles for lookup‑like behavior: traditional container semantics and modern, explicit method‑based APIs. Both have a place, but I recommend a default approach based on how “collection‑like” your class truly is.

Approach

When I use it

Example

Typical gotcha

Traditional getitem

Objects that are clearly containers or views

users[0], row["email"]

Hidden expensive work

Modern explicit methods

Service or domain objects with side effects

client.fetch_user(id)

Verbose call sitesI usually start with getitem for local, in‑memory data or cheap calculations. If I later add network access or heavy transforms, I migrate to explicit methods so the call site signals cost. This is one of the most important “API honesty” decisions you can make.

Advanced edge cases: negative indexes, slices, and custom ranges

Python’s indexing rules include negative indexes and open‑ended slices. If your object pretends to be sequence‑like, you should support those behaviors or explain why you don’t. I’ve found that accepting negative indexes is easy when you delegate to a list or sequence.

Here’s a custom time‑series class that supports negative indexes and slices. I also normalize slices so the returned value is another TimeSeries instance, not a bare list, which keeps chaining natural.

class TimeSeries:

def init(self, points):

self._points = list(points)

def getitem(self, key):

if isinstance(key, slice):

return TimeSeries(self._points[key])

return self._points[key]

def iter(self):

return iter(self._points)

def len(self):

return len(self._points)

series = TimeSeries([10, 12, 14, 13, 15])

print(series[-1]) # 15

print(list(series[1:4])) # [12, 14, 13]

Notice that chaining works: series[1:4][0] returns 12, and iteration yields raw points. This kind of consistency reduces confusion.

For custom ranges, consider accepting a dedicated key object instead of overloading tuple semantics. For example, a Range class can make calls like metrics[Range("2025‑01", "2025‑03")] more explicit and easier to type‑check.

Error handling that feels like Python

One of the fastest ways to make a class feel “native” is to match Python’s error types and messages. I try to align with these conventions:

  • Wrong key type: TypeError
  • Missing key in mapping: KeyError
  • Index out of range: IndexError

If you’re unsure, simulate the built‑in behavior in the REPL and copy it. Users subconsciously expect these errors, and tests are often written against them.

Here’s a small example with explicit error messages that follow the pattern:

class SeatMap:

def init(self, seats):

self._seats = dict(seats)

def getitem(self, key):

if not isinstance(key, str):

raise TypeError("Seat key must be a string like ‘A1‘")

try:

return self._seats[key]

except KeyError:

raise KeyError(f"Seat not found: {key}")

The message can be custom, but the exception type should be right. That matters for tooling and tests.

Testing getitem the way users will break it

Whenever I add getitem, I write tests for three categories: happy path, boundary conditions, and bad input. The tests are short but extremely valuable because they capture the contract your object promises.

A simple test checklist I follow:

  • Normal access with common key types
  • Negative indexes (if sequence‑like)
  • Slices with step values
  • Missing keys for mapping behavior
  • Bad key types and error types

If you’re in a typed codebase, I also add type hints and run a static checker. getitem is a key spot where type‑checking helps because it reveals ambiguous returns. If your return type changes based on the key, consider using @overload to clarify behavior.

Practical guidance you can apply today

When you implement getitem, you’re making an API promise. Here’s the guidance I actually apply in my own code, distilled into a few actionable points:

  • Choose a single mental model: sequence or mapping. Mixing them is fine only when the domain truly calls for it.
  • Keep key types narrow. If you allow too many shapes, you lose predictability.
  • Match built‑in error types. It makes your object feel native and testable.
  • Support slices for sequence‑like classes. It’s expected behavior.
  • Keep getitem cheap and side‑effect‑free. Brackets should feel like memory access.

If you follow those rules, your objects will feel natural and remain easy to maintain.

Closing

If you only take one idea from this guide, let it be this: getitem is not just syntax sugar; it’s a contract with your users. When you implement it thoughtfully, you make your objects feel like part of Python itself. I recommend deciding early whether your class is a sequence, a mapping, or a hybrid, and then sticking to that story everywhere else in the API. That means aligning error types, iteration behavior, and return shapes so everything fits together.

In my experience, the most successful uses of getitem are simple and honest. The key is narrow, the work is cheap, and the result is predictable. If you need to do heavy work or call a remote service, push that behind explicit methods so the call sites communicate cost. If you need to support slices or tuple indexing, document it and test it aggressively. These small choices are what make a codebase feel stable over time.

Your next step is straightforward: find one class in your codebase that feels a bit awkward to use and consider whether getitem could make it more natural. Start with a single key type, add tests for edge cases, and keep the behavior aligned with Python’s built‑ins. That’s usually enough to make the improvement obvious and durable.

Scroll to Top