Back to Blog
C#

C# Indexer: Syntax, Usage, and Performance

c# indexer: Learn how to define and use C# indexers to access elements in custom collections, including syntax, overloads, and performance considerations.

indexerC# collectionsproperty accessoverloadingperformance
Illustration of a C# indexer concept showing an array-like access on a custom object.

When you wrap a collection inside a class, you often want consumers to access elements with array-like syntax. A C# indexer gives you exactly that: a way to expose indexed access on your own types. Instead of writing GetItem(index) and SetItem(index, value), you can use obj[index] directly. This article explains how to declare indexers, how they behave with different parameter types, and where they carry runtime costs you should know about.

Declaring an Indexer in C#

The syntax for an indexer resembles a property, but it uses this and bracket parameters. Here is the simplest form:

public class StringCollection { private readonly string[] _items; public StringCollection(string[] items) { _items = items; } public string this[int index] { get => _items[index]; set => _items[index] = value; } }

The this[int index] declaration defines the indexer. The parameter list can contain one or more parameters, and the accessors work like property accessors. In the getter, you return the element at the given index; in the setter, you assign the incoming value to that position. The value keyword is implicit, just as in properties.

You can also make the indexer read-only by omitting the set accessor, or write-only by omitting get. This is useful when you want to expose a collection that consumers can read but not modify, or when the indexer represents a computed value that has no meaningful getter.

Using Indexers with Custom Collections

Indexers shine when your class encapsulates a collection and you want to expose a natural access pattern. Consider a ShoppingCart that internally stores a list of line items:

public class ShoppingCart { private readonly List<LineItem> _items = new(); public LineItem this[int index] { get => _items[index]; set => _items[index] = value; } public void Add(LineItem item) => _items.Add(item); }

Now you can write cart[0] to get the first line item. This is more readable than cart.GetItem(0) and matches the mental model of a collection. The indexer does not have to map to a real array or list; you can compute the returned value on the fly. For example, a TemperatureScale class might expose this[int celsius] that converts to Fahrenheit, but that is better done with a method unless you have a strong reason to use indexer syntax.

Overloading Indexers for Different Parameter Types

Indexers can be overloaded just like methods. You can define multiple indexers on the same class as long as their parameter lists differ. This is common when you want to support both integer and string keys. A LookupTable class might allow access by index or by name:

public class LookupTable { private readonly Dictionary<string, string> _byName = new(); private readonly List<string> _byIndex = new(); public string this[int index] { get => _byIndex[index]; set => _byIndex[index] = value; } public string this[string name] { get => _byName[name]; set => _byName[name] = value; } }

Overloading lets you offer a uniform access syntax for different key types. However, be careful not to overuse it. If the indexer semantics are ambiguous, a named method like GetByName is clearer. Overloads are most effective when the indexer genuinely represents a collection lookup for each key type.

Indexers vs Properties and Methods

Indexers are not a replacement for properties. Properties expose a single named value; indexers expose a collection of values accessed by a key. Use a property when you have a fixed, named attribute. Use an indexer when the class models a collection or a mapping. For example, a Matrix class naturally uses a two-dimensional indexer this[int row, int col], whereas a Person class should use properties like Name and Age.

Methods are more versatile when you need to pass additional parameters or perform complex operations. An indexer is essentially syntactic sugar for a pair of get and set methods, but it communicates intent more clearly. If your accessor requires more than one key, an indexer is still appropriate; if it requires parameters that are not keys, a method is better.

Performance Considerations for Indexers

An indexer itself does not add meaningful overhead beyond the code inside its accessors. The runtime cost depends on what you do in the getter or setter. If you simply delegate to an array or list, the cost is the same as accessing the underlying collection directly. However, if the indexer performs validation, conversion, or locking, that cost is incurred on every access.

One common performance mistake is using an indexer that iterates over a collection to find a match. For example:

public string this[string name] { get { foreach (var item in _items) { if (item.Name == name) return item.Value; } throw new KeyNotFoundException(); } }

This is O(n) per lookup. If you need frequent lookups by name, store the data in a Dictionary<string, string> instead, so the indexer becomes O(1). The indexer is not the bottleneck; the underlying data structure is. When you design an indexer, think about the access pattern and choose the backing store accordingly.

Another consideration is that indexers can be used in hot loops. If the getter allocates a new object each time, that allocation pressure can affect performance. Keep the accessors lean and avoid unnecessary allocations unless the scenario genuinely requires them.

Common Pitfalls and How to Avoid Them

One common pitfall is forgetting to handle out-of-range indices. If your indexer wraps an array, an invalid index will throw an IndexOutOfRangeException. If it wraps a dictionary, a missing key throws KeyNotFoundException. Decide whether you want to let these exceptions propagate or whether you want to return a default value. Returning a default can hide bugs, so it is often better to let the exception surface.

Another issue is using an indexer for a property that is not a collection. If your class has a single value that you want to expose with bracket syntax, that is usually a poor design. Indexers imply multiple elements or a keyed lookup. If you find yourself writing this[0] for a single scalar, reconsider the design.

Also, be aware that indexers cannot be static. They are instance members because they operate on the state of a specific object. If you need static indexed access, use a static method instead.

Advanced Indexer Usage: Multi-Dimensional and Ranges

Indexers can accept multiple parameters, enabling multi-dimensional access. A Matrix class can define this[int row, int col] to return a cell value. This is straightforward:

public class Matrix { private readonly double[,] _data; public double this[int row, int col] { get => _data[row, col]; set => _data[row, col] = value; } }

In modern C#, you can also define an indexer that returns a range or a span. For example, a custom collection can expose a slice by accepting a Range parameter:

public int[] this[Range range] { get => _items[range]; }

This allows collection[1..3] to return a subarray. The Range type is part of the standard library and works with arrays, strings, and spans. If your class wraps an array, you can delegate directly to the underlying array's range support. If not, you need to implement the slicing logic yourself, which may involve copying elements.

When you add a range indexer, be careful about the return type. Returning a new array each time can be expensive for large slices. Consider returning a ReadOnlySpan<T> if the underlying data is contiguous and you do not need to mutate it, but note that spans are stack-only and cannot be stored in fields or used in async methods. For most cases, returning a new array is acceptable if the slices are small or infrequent.

Indexers are a powerful feature when used with discipline. They make custom collections feel like native arrays and dictionaries, but they also put the responsibility on you to choose the right backing store and to keep accessors efficient. By understanding the syntax, overloads, and performance implications, you can use indexers to create clean, intuitive APIs without sacrificing runtime behavior.

c# indexer: Practical Usage and Code Examples | RYUSLOG DEV