Home C# / .NET C# Indexers Explained — Syntax, Real-World Use Cases and Pitfalls

C# Indexers Explained — Syntax, Real-World Use Cases and Pitfalls

In Plain English 🔥
Imagine a library where instead of asking the librarian 'can you get me the book stored in slot number 5?', you just walk up and grab shelf[5] yourself. An indexer is what lets YOUR custom object behave like that shelf — you define the rules for what happens when someone uses square brackets on it. It's not magic, it's just a special property with a parameter.
⚡ Quick Answer
Imagine a library where instead of asking the librarian 'can you get me the book stored in slot number 5?', you just walk up and grab shelf[5] yourself. An indexer is what lets YOUR custom object behave like that shelf — you define the rules for what happens when someone uses square brackets on it. It's not magic, it's just a special property with a parameter.

Most C# developers discover indexers the moment they start digging into how List or Dictionary actually work under the hood. You use myList[0] every day without a second thought — but that bracket notation isn't some built-in language primitive reserved for arrays. It's a feature you can implement yourself on any class you write. That's what makes indexers genuinely powerful.

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.

TemperatureLog.cs · CSHARP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
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}");
        }
    }
}
▶ Output
9 AM : 18.5 °C
Noon : 22.1 °C
6 PM : 19.8 °C
Error: Hour must be between 0 and 23. (Parameter 'hour')
⚠️
Pro Tip:Always validate the index inside your get and set accessors. Callers expect the same safety contract they get from List — an ArgumentOutOfRangeException with a meaningful message is infinitely more helpful than an IndexOutOfRangeException from your private array two stack frames deep.

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.TryGetValue and prevents callers from wrapping every access in a try-catch. That's a deliberate API design choice, not an accident.

AppSettings.cs · CSHARP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
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}");
    }
}
▶ Output
Theme: Dark
Max retries: 3
Cache timeout (default): 60s
Theme after removal: (not set)
Total settings remaining: 2
🔥
Design Insight:Returning null from a missing-key getter is a valid and common pattern — but it forces you to declare the return type as nullable (string?). If your class semantics mean a missing key is always a programming error rather than expected, throw a KeyNotFoundException instead. Be deliberate; document whichever contract you choose.

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.

SpreadsheetGrid.cs · CSHARP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
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
    }
}
▶ Output
Positional [0,0]: Name
Excel-style A1 : Name

[0,1] = Score, B1 = Score
[1,0] = Alice, A2 = Alice
[1,2] = A, C2 = A
⚠️
Pro Tip:When you overload indexers, delegate the secondary overload's logic to the primary one rather than duplicating validation code. In the example above, the string indexer calls this[row, col] internally. This means validation only lives in one place — a key maintainability win.

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.

CountryCodeLookup.cs · CSHARP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
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"]}");
    }
}
▶ Output
US => United States
gb => United Kingdom
JP => Japan
ZZ => Unknown (ZZ)

Contains 'DE': True
DE full name : Germany
🔥
Interview Gold:Interviewers love asking whether indexers can be defined in interfaces. The answer is yes — and it's a powerful pattern for dependency injection. Declaring only 'get' in the interface enforces read-only access at the contract level, even if the implementation allows writes internally.
Feature / AspectIndexerRegular Method (GetItem/SetItem)
Syntax at call siteobj[key] — familiar, conciseobj.GetItem(key) — verbose, custom name
DiscoverabilityIntuitive for any developerCaller must learn your method name
Multiple overloadsYes — by parameter type/countYes — but adds more method names
Usable in interfacesYes — get and/or setYes
Expression-bodied syntaxYes — for simple gettersYes
Supports multiple paramsYes — this[int r, int c]Yes — but no syntactic benefit
Works with LINQ / foreachNo — indexer alone is not enough; IEnumerable needed separatelySame limitation
Best fitCollection-like or keyed-access typesTypes 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. You can have an indexer without IEnumerable and vice versa. If you want both, implement IEnumerable in addition to your indexer — List does exactly this.

🔥
TheCodeForge Editorial Team Verified Author

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.

← PreviousOperator Overloading in C#Next →Reflection in C#
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged