C# Indexers Explained — Syntax, Real-World Use Cases and Pitfalls
Most C# developers discover indexers the moment they start digging into how List
Before indexers existed, if you wanted your custom collection or data-wrapper class to support element access, you'd be stuck writing verbose GetItem(int index) and SetItem(int index, T value) methods. Users of your class would have to learn a bespoke API instead of reaching for the intuitive bracket syntax they already know. Indexers solve this by letting you define exactly what obj[key] means for your type — whether the key is an integer, a string, an enum, or anything else.
By the end of this article you'll understand not just how to declare an indexer, but when it's the right design choice, how to build multi-parameter and overloaded indexers, and the subtle bugs that catch even experienced developers off guard. You'll walk away ready to write cleaner APIs and answer indexer questions confidently in a technical interview.
What an Indexer Actually Is (and Why It Beats a Plain Method)
An indexer is a special kind of property that accepts one or more parameters — the index values inside the square brackets. Like a regular property it has a get accessor and an optional set accessor, but instead of using the property name as the access point, callers use bracket notation on the object itself.
The key design motivation is expressiveness. Compare these two lines:
string city = addressBook.GetEntry("Alice"); string city = addressBook["Alice"];
The second line is immediately obvious to anyone who has used a dictionary or array. The first forces the caller to learn your method name. When your class semantically represents a collection or a keyed store of data, an indexer makes your API feel native to the language.
Indexers are declared using the this keyword followed by the parameter list in square brackets — think of this as saying 'when someone indexes into me, here is what to do'. They live on the class just like properties, and they fully support access modifiers, readonly patterns (get-only), and even expression-bodied syntax for simple cases.
using System; using System.Collections.Generic; /// <summary> /// Stores hourly temperature readings for a single day. /// Demonstrates the most fundamental indexer pattern. /// </summary> public class TemperatureLog { // Internal storage — callers never touch this directly private readonly double[] _hourlyReadings = new double[24]; // --- THE INDEXER --- // 'this[int hour]' is the syntax. 'hour' is the index parameter. // It behaves exactly like a property but accepts an argument. public double this[int hour] { get { // Validate before blindly returning data if (hour < 0 || hour > 23) throw new ArgumentOutOfRangeException(nameof(hour), "Hour must be between 0 and 23."); return _hourlyReadings[hour]; } set { if (hour < 0 || hour > 23) throw new ArgumentOutOfRangeException(nameof(hour), "Hour must be between 0 and 23."); // 'value' is the implicit keyword, just like in a regular property setter _hourlyReadings[hour] = value; } } } class Program { static void Main() { var todayLog = new TemperatureLog(); // SET via indexer — reads just like array assignment todayLog[9] = 18.5; // 9 AM reading todayLog[12] = 22.1; // noon reading todayLog[18] = 19.8; // 6 PM reading // GET via indexer Console.WriteLine($"9 AM : {todayLog[9]} °C"); Console.WriteLine($"Noon : {todayLog[12]} °C"); Console.WriteLine($"6 PM : {todayLog[18]} °C"); // This will throw ArgumentOutOfRangeException — hour 25 is invalid try { double bad = todayLog[25]; } catch (ArgumentOutOfRangeException ex) { Console.WriteLine($"Error: {ex.Message}"); } } }
Noon : 22.1 °C
6 PM : 19.8 °C
Error: Hour must be between 0 and 23. (Parameter 'hour')
String-Keyed Indexers — Modeling a Real Configuration Store
Indexers don't have to use integers. Any type can be an index — and string-keyed indexers are especially common in the real world. ASP.NET's IConfiguration, HttpContext.Items, and ViewData all use string indexers. If you've ever written config["ConnectionStrings:Default"] you've used one.
A string indexer is ideal when your class wraps a keyed data store and you want callers to access values by name rather than position. It communicates intent clearly: this isn't a list, it's a lookup.
The example below models a lightweight application settings store. Notice how we return null for missing keys in the getter rather than throwing — this mirrors the design of Dictionary
using System; using System.Collections.Generic; /// <summary> /// A simple key-value settings store with a string indexer. /// Models the same access pattern as IConfiguration in ASP.NET Core. /// </summary> public class AppSettings { // Backing store — private so callers can't manipulate it directly private readonly Dictionary<string, string> _settings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); // String indexer — key is case-insensitive thanks to the comparer above public string? this[string key] { get { // Return null for missing keys instead of throwing — // lets callers use the null-coalescing operator cleanly return _settings.TryGetValue(key, out string? found) ? found : null; } set { if (string.IsNullOrWhiteSpace(key)) throw new ArgumentException("Setting key cannot be empty.", nameof(key)); if (value is null) // Treat assigning null as 'remove this setting' _settings.Remove(key); else _settings[key] = value; } } public int Count => _settings.Count; } class Program { static void Main() { var settings = new AppSettings(); // Write settings via indexer settings["Theme"] = "Dark"; settings["MaxRetries"] = "3"; settings["DatabaseTimeout"] = "30"; // Read back — case-insensitive because of our StringComparer Console.WriteLine($"Theme: {settings["theme"]}"); // lowercase key still works Console.WriteLine($"Max retries: {settings["MaxRetries"]}"); // Null-coalescing works great with a null-returning getter string timeout = settings["CacheTimeout"] ?? "60"; // key doesn't exist, use default Console.WriteLine($"Cache timeout (default): {timeout}s"); // Remove a setting by assigning null settings["Theme"] = null; Console.WriteLine($"Theme after removal: {settings["Theme"] ?? "(not set)"}"); Console.WriteLine($"Total settings remaining: {settings.Count}"); } }
Max retries: 3
Cache timeout (default): 60s
Theme after removal: (not set)
Total settings remaining: 2
Overloaded and Multi-Parameter Indexers — When One Index Isn't Enough
C# lets you overload indexers just like methods — the same class can have multiple indexers as long as their parameter signatures differ. This is genuinely useful when your type can be meaningfully accessed by more than one kind of key.
You can also define indexers that take multiple parameters, separated by commas inside the brackets. This fits naturally for two-dimensional or grid-based data structures where obj[row, col] is far more readable than obj.GetCell(row, col).
The example below models a spreadsheet grid. It exposes both a two-integer indexer for positional access and a string indexer that parses Excel-style cell addresses like "B3". Both indexers share the same underlying storage. This gives callers the flexibility to use whichever style fits their context — and the class looks and feels like a first-class citizen of the language.
using System; /// <summary> /// A simple 2D grid that supports both positional and Excel-style cell access. /// Demonstrates multi-parameter indexers AND indexer overloading. /// </summary> public class SpreadsheetGrid { private readonly string[,] _cells; public int Rows { get; } public int Columns { get; } public SpreadsheetGrid(int rows, int columns) { Rows = rows; Columns = columns; _cells = new string[rows, columns]; } // OVERLOAD 1: Positional access — grid[row, col] // Multi-parameter indexers use a comma-separated list, just like multi-dim arrays public string this[int row, int col] { get { ValidateBounds(row, col); return _cells[row, col] ?? string.Empty; } set { ValidateBounds(row, col); _cells[row, col] = value; } } // OVERLOAD 2: Excel-style access — grid["B3"] means column B (index 1), row 3 (index 2) // This overload has a DIFFERENT parameter type (string vs int, int) // so the compiler knows which one to call public string this[string cellAddress] { get { (int row, int col) = ParseAddress(cellAddress); return this[row, col]; // delegate to the positional indexer — no code duplication } set { (int row, int col) = ParseAddress(cellAddress); this[row, col] = value; // same delegation pattern } } // Parses "B3" into (row: 2, col: 1) — zero-based private static (int row, int col) ParseAddress(string address) { if (string.IsNullOrEmpty(address) || address.Length < 2) throw new ArgumentException($"Invalid cell address: '{address}'"); char columnLetter = char.ToUpper(address[0]); int col = columnLetter - 'A'; // 'A' -> 0, 'B' -> 1, etc. int row = int.Parse(address[1..]) - 1; // "3" -> row index 2 (zero-based) return (row, col); } private void ValidateBounds(int row, int col) { if (row < 0 || row >= Rows || col < 0 || col >= Columns) throw new ArgumentOutOfRangeException( $"Cell ({row},{col}) is outside grid bounds ({Rows}x{Columns})."); } } class Program { static void Main() { var sheet = new SpreadsheetGrid(rows: 5, columns: 4); // Write using positional indexer (row, col) — zero-based sheet[0, 0] = "Name"; sheet[0, 1] = "Score"; sheet[1, 0] = "Alice"; sheet[1, 1] = "95"; // Write using Excel-style indexer — 1-based, column letter sheet["C1"] = "Grade"; // row 0, col 2 sheet["C2"] = "A"; // row 1, col 2 // Read using both styles — they access the same underlying data Console.WriteLine($"Positional [0,0]: {sheet[0, 0]}"); Console.WriteLine($"Excel-style A1 : {sheet["A1"]}"); Console.WriteLine(); Console.WriteLine($"[0,1] = {sheet[0, 1]}, B1 = {sheet["B1"]}"); // same cell Console.WriteLine($"[1,0] = {sheet[1, 0]}, A2 = {sheet["A2"]}"); // same cell Console.WriteLine($"[1,2] = {sheet[1, 2]}, C2 = {sheet["C2"]}"); // same cell } }
Excel-style A1 : Name
[0,1] = Score, B1 = Score
[1,0] = Alice, A2 = Alice
[1,2] = A, C2 = A
Read-Only Indexers and Interface Contracts — The Pattern You'll See in Real Codebases
Not every indexer should allow writes. A read-only indexer (get accessor only) is the right choice when your class exposes data that callers should query but never mutate directly — think of a compiled lookup table, a cached result set, or an immutable view over some underlying data.
Indexers can also be declared in interfaces, which is the key to writing testable, mockable code. When ASP.NET Core defines IHeaderDictionary with a string indexer, any class that implements that interface must provide that indexer. Your own interfaces can do exactly the same thing.
The example below shows a read-only country code lookup paired with an interface. Notice that the interface declares only a getter — implementing classes can add a setter internally if they need it, but callers holding a reference to the interface can't write through it. That's the encapsulation story indexers tell best.
using System; using System.Collections.Generic; /// <summary> /// Interface declares a read-only string indexer. /// Any class implementing this can be swapped out or mocked in tests. /// </summary> public interface ICountryLookup { // Read-only indexer in an interface — only 'get' is declared string? this[string isoCode] { get; } bool Contains(string isoCode); } /// <summary> /// Concrete implementation — backed by a static dictionary. /// Could be swapped for a database-backed version without changing callers. /// </summary> public class CountryCodeLookup : ICountryLookup { private readonly Dictionary<string, string> _countries; public CountryCodeLookup() { // Pre-populated with a handful of ISO 3166-1 alpha-2 codes _countries = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { ["US"] = "United States", ["GB"] = "United Kingdom", ["DE"] = "Germany", ["JP"] = "Japan", ["BR"] = "Brazil" }; } // Implements the interface's read-only indexer // The class itself only exposes get — there's no way for callers to pollute the data public string? this[string isoCode] => _countries.TryGetValue(isoCode, out string? name) ? name : null; public bool Contains(string isoCode) => _countries.ContainsKey(isoCode); } /// <summary> /// Consumes ICountryLookup — it only knows about the interface, /// not the concrete class. Fully testable. /// </summary> public class CountryReportGenerator { private readonly ICountryLookup _lookup; public CountryReportGenerator(ICountryLookup lookup) { _lookup = lookup; } public void PrintCountryName(string isoCode) { // Uses the indexer through the interface reference string display = _lookup[isoCode] ?? $"Unknown ({isoCode})"; Console.WriteLine($"{isoCode.ToUpper()} => {display}"); } } class Program { static void Main() { ICountryLookup lookup = new CountryCodeLookup(); var generator = new CountryReportGenerator(lookup); generator.PrintCountryName("US"); generator.PrintCountryName("gb"); // lowercase — case-insensitive generator.PrintCountryName("JP"); generator.PrintCountryName("ZZ"); // not in the lookup // Direct interface usage — read-only, clean API Console.WriteLine(); Console.WriteLine($"Contains 'DE': {lookup.Contains("DE")}"); Console.WriteLine($"DE full name : {lookup["DE"]}"); } }
gb => United Kingdom
JP => Japan
ZZ => Unknown (ZZ)
Contains 'DE': True
DE full name : Germany
| Feature / Aspect | Indexer | Regular Method (GetItem/SetItem) |
|---|---|---|
| Syntax at call site | obj[key] — familiar, concise | obj.GetItem(key) — verbose, custom name |
| Discoverability | Intuitive for any developer | Caller must learn your method name |
| Multiple overloads | Yes — by parameter type/count | Yes — but adds more method names |
| Usable in interfaces | Yes — get and/or set | Yes |
| Expression-bodied syntax | Yes — for simple getters | Yes |
| Supports multiple params | Yes — this[int r, int c] | Yes — but no syntactic benefit |
| Works with LINQ / foreach | No — indexer alone is not enough; IEnumerable needed separately | Same limitation |
| Best fit | Collection-like or keyed-access types | Types where access is a side-effectful operation |
🎯 Key Takeaways
- An indexer is declared with 'this[parameterType param]' and gives your class bracket-notation access — it's a property with a parameter, not a standalone language feature.
- Use string indexers when your type is semantically a keyed store; use int indexers when it's positional; use multi-parameter indexers for grid or matrix types where obj[row, col] reads naturally.
- Indexers can be declared in interfaces — declaring only a getter in the interface enforces a read-only contract for all consumers, even if the implementation supports writes internally.
- Overload indexers by parameter type or count, and delegate secondary overloads to the primary one to keep validation logic in a single place and avoid subtle inconsistency bugs.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Forgetting to validate the index in get and set — Symptom: an IndexOutOfRangeException from your private array, with a confusing stack trace that points inside your class rather than at the caller's bad input. Fix: always check bounds or key validity at the top of both accessors and throw ArgumentOutOfRangeException or ArgumentException with a clear message before touching the backing store.
- ✕Mistake 2: Defining an indexer on a type that isn't conceptually a collection — Symptom: other developers are confused about what bracket notation means on your class (e.g., order[3] on an Order class — does it mean the 4th item? the 4th property?). Fix: only add an indexer when your type semantically represents a keyed store or collection. If the access is not naturally 'retrieve element by key', use a named method instead.
- ✕Mistake 3: Returning a mutable reference type from an indexer without cloning it — Symptom: callers can mutate the returned object and silently corrupt your class's internal state, because they received the actual reference not a copy. Fix: return a defensive copy for value-sensitive types, or document clearly that the returned object is live. For immutable types (string, structs, records) this is not an issue.
Interview Questions on This Topic
- QCan you define an indexer in a C# interface? If so, what's the difference between declaring only a getter versus both a getter and setter in the interface?
- QHow does an indexer differ from a property in C#? When would you choose to add an indexer to a class instead of just adding a named method like GetItem()?
- QIf a class has two indexers — one that takes an int and one that takes a string — how does the compiler decide which one to call? What happens if you try to define two indexers that both take a single int parameter?
Frequently Asked Questions
Can a C# class have more than one indexer?
Yes. C# supports indexer overloading — you can define multiple indexers on the same class as long as their parameter signatures are different. For example, one indexer taking an int and another taking a string is perfectly valid. The compiler resolves which one to call based on the argument type you pass inside the brackets.
What is the difference between an indexer and a property in C#?
A property is accessed by name (person.Name) and takes no parameters. An indexer is accessed through bracket notation on the object itself (collection[key]) and requires at least one parameter. Both have get and set accessors and support access modifiers, but indexers are specifically designed to give your class array-like or dictionary-like access semantics.
Does adding an indexer to a class automatically make it iterable with foreach?
No — these are separate concerns. An indexer gives you bracket access (obj[0]) but foreach requires the class to implement IEnumerable or IEnumerable
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.