Mid-level 7 min · March 06, 2026

C# Indexers — NullReference Ambush in String-Indexed Stores

Unexpected NullReferenceException from a string-indexed store? Ensure your indexer throws KeyNotFoundException, not null.

N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Everything here is grounded in real deployments.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Indexers let custom C# types support bracket notation (obj[key]) for element access, just like arrays or dictionaries.
  • Declared with 'this[parameterType param]' and get/set accessors — a property with a parameter, not a standalone feature.
  • Index keys can be int, string, enum, or multiple parameters — overload by signature same as methods.
  • Performance: indexers are JIT-inlineable, often zero overhead vs a manual method; validation logic adds runtime cost.
  • Production insight: missing null checks or bounds validation in indexers cause confusing crashes deep in internal arrays, not at the call site.
✦ Definition~90s read
What is Indexers in C#?

An indexer is a C# language feature that lets you treat an object like an array, using square-bracket syntax (obj[key]) to get or set values. It exists because many real-world data stores—configuration dictionaries, in-memory caches, or ORM entity collections—are conceptually key-value maps, not positional arrays.

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.

Without indexers, you'd write verbose GetValue(string key) and SetValue(string key, object value) methods everywhere; with them, the calling code reads naturally, like config["ConnectionString"] = "...". The compiler desugars indexers into special get_Item/set_Item methods, so they're just syntactic sugar—but sugar that dramatically reduces noise in data-access-heavy codebases.

String-keyed indexers are the most common variant outside of collections. You'll see them in custom configuration stores, plugin registries, or any place where you need a typed wrapper around a Dictionary<string, object>. The danger—and the reason this article exists—is that a string-keyed indexer returns null for missing keys by default, which silently propagates NullReferenceException when you chain calls like config["Db"]["Timeout"].

Senior engineers watch for this: unlike Dictionary.TryGetValue, a custom indexer gives no compile-time safety. Alternatives include using Nullable<T> return types, throwing KeyNotFoundException explicitly, or—when performance matters—avoiding indexers entirely in hot paths because the virtual dispatch and bounds checking can't be inlined as aggressively as direct array access.

Indexers also support overloading (multiple parameter types), multi-dimensional signatures (obj[row, col]), and read-only contracts via interfaces like IReadOnlyDictionary<TKey, TValue>. In production codebases, you'll see them used sparingly—typically only when the object's primary purpose is indexed access.

If you find yourself adding indexers to business-logic classes, reconsider: a named method like GetCustomerByOrderId is almost always clearer than customers[orderId]. The feature shines in infrastructure code (caches, configs, serialization wrappers) but becomes a readability liability when overused in domain models.

Plain-English First

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<T> or Dictionary<TKey, TValue> 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.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
using System;

namespace io.thecodeforge
{
    /// <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}");
            }
        }
    }
}
Pro Tip:
Always validate the index inside your get and set accessors. Callers expect the same safety contract they get from List<T> — an ArgumentOutOfRangeException with a meaningful message is infinitely more helpful than an IndexOutOfRangeException from your private array two stack frames deep.
Production Insight
Skipping validation in indexers shifts the error from 'invalid index' (clear) to 'internal array out of bounds' (confusing).
Production logs will show the error inside your class, not at the call site, making debugging harder.
Rule: validate the index at the top of both accessors — it costs one comparison and saves hours of debugging.
Key Takeaway
An indexer is a property with a parameter.
Validate the parameter before touching backing storage.
Callers deserve a clear exception, not an internal crash.
C# Indexers: String-Keyed Store Pitfalls THECODEFORGE.IO C# Indexers: String-Keyed Store Pitfalls Flow from indexer definition to null-reference trap in config maps Indexer Declaration this[string key] { get; set; } String-Keyed Store Dictionary backing field Null Key Access Missing key returns null reference NullReferenceException Caller assumes non-null value TryGetValue Pattern Check key existence before use ⚠ String-indexed getter returns null for missing keys Always validate key or use TryGetValue to avoid NRE THECODEFORGE.IO
thecodeforge.io
C# Indexers: String-Keyed Store Pitfalls
Indexers Csharp

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

AppSettings.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
using System;
using System.Collections.Generic;

namespace io.thecodeforge
{
    /// <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}");
        }
    }
}
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.
Production Insight
Null-returning indexers force every caller to handle null, which is often forgotten.
When a new consumer gets a null and doesn't check it, it crashes with NullReferenceException at a seemingly unrelated line.
Rule: if missing keys are exceptional, throw KeyNotFoundException; if expected, always check for null at call sites.
Key Takeaway
String indexers are ideal for keyed stores like configuration.
Decide: null for missing key (safe, verbose callers) vs throw (enforces contract).
Document your choice — don't leave callers guessing.

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.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
using System;

namespace io.thecodeforge
{
    /// <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
        }
    }
}
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.
Production Insight
Delegating overloads eliminates duplicate validation logic but introduces a risk of infinite recursion if overloads call each other accidentally.
If the string indexer calls the int indexer, and the int indexer also calls the string indexer, you get a StackOverflowException.
Rule: pick one 'primary' overload and have all others delegate to it — never create circular delegation.
Key Takeaway
Overload indexers by signature (int vs string).
Delegate secondary overloads to the primary one.
Avoid circular delegation — it causes infinite recursion.

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.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
using System;
using System.Collections.Generic;

namespace io.thecodeforge
{
    /// <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"]}");
        }
    }
}
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.
Production Insight
An interface with a read-only indexer is not truly read-only when the implementation returns a mutable reference type.
Mistake: returning a List<T> directly instead of a read-only wrapper. Callers can cast and mutate the list.
Rule: return immutable wrappers or defensive copies from interface indexers that promise read-only access.
Key Takeaway
Indexers work in interfaces — declare get-only to enforce read-only at the contract level.
Implementations can still have a setter internally, visible only through the concrete type.
Be careful with mutable references: interface contract != immutability of returned data.

Indexer Performance and Thread Safety — What Senior Engineers Watch For

Indexers are methods under the hood. The JIT can inline simple getters and setters, so for trivial cases they have zero overhead compared to a manually written method. But when you add validation, exception handling, or delegation to another method, the inlining budget shrinks.

More importantly, indexers are NOT inherently thread-safe. If two threads write and read through the same indexer concurrently, you get torn reads, corrupted state, or stale data. A common mistake: assuming that because you're using a collection as backing store, the indexer is safe. Dictionary<TKey,TValue> has internal locks only for some operations, but even then, concurrent indexer calls can cause exceptions or data loss.

This section shows a thread-safe indexer using a ReaderWriterLockSlim for a scenarios where reads are frequent and writes are rare. For high-throughput scenarios, consider using ConcurrentDictionary<TKey,TValue> and exposing its indexer directly.

ThreadSafeConfig.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
using System;
using System.Collections.Generic;
using System.Threading;

namespace io.thecodeforge
{
    /// <summary>
    /// A thread-safe settings store using ReaderWriterLockSlim.
    /// Optimized for many reads, few writes.
    /// </summary>
    public class ThreadSafeConfig
    {
        private readonly Dictionary<string, string> _store
            = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();

        public string? this[string key]
        {
            get
            {
                _lock.EnterReadLock();
                try
                {
                    return _store.TryGetValue(key, out string? value) ? value : null;
                }
                finally
                {
                    _lock.ExitReadLock();
                }
            }
            set
            {
                _lock.EnterWriteLock();
                try
                {
                    if (value is null)
                        _store.Remove(key);
                    else
                        _store[key] = value;
                }
                finally
                {
                    _lock.ExitWriteLock();
                }
            }
        }

        public int Count
        {
            get
            {
                _lock.EnterReadLock();
                try { return _store.Count; }
                finally { _lock.ExitReadLock(); }
            }
        }
    }
}
Performance Trade-off:
ReaderWriterLockSlim adds overhead of about 50-100ns per lock acquisition. For most application-level caches, this is fine. But if your indexer is called millions of times per second (e.g., in a hot loop), consider using a lock-free collection like ConcurrentDictionary or immutable snapshots.
Production Insight
Thread-safe indexers with locks can become painful bottlenecks under high concurrency.
One bad experience: a global settings indexer protected by a ReaderWriterLockSlim that was called 50K times per second — read contention caused 30% CPU waste on lock overhead.
Rule: measure before adding locks. Use ConcurrentDictionary or immutable snapshots for high-read scenarios.
Key Takeaway
Indexers are not thread-safe by default.
Use ReaderWriterLockSlim for read-heavy scenarios, ConcurrentDictionary for general purpose.
Always measure lock contention in production — locks are a concurrency cure but a performance poison.

Indexers with Access Modifiers — Locking Down Your Virtual Array

You already know indexers behave like virtual arrays. But here's the part most tutorials skip: you can slap access modifiers on the get and set accessors individually. That means you can expose a read-only indexer to the outside world while keeping a private setter for internal mutation.

Why does this matter? Because real production code doesn't trust consumers. Your configuration store might let anyone read a value, but only the initialization routine should write one. Slap private set; on that indexer and move on. No extra methods. No defensive copies. The compiler enforces it.

This isn't just about security. It's about signalling intent. When a junior sees a public get and a private set, they immediately understand: "This is read-mostly data, don't mess with it after construction." That clarity saves hours of debugging later. Don't hide your constraints — encode them in the type system.

ConfigWithAccessModifiers.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// io.thecodeforge — csharp tutorial

public class ConfigStore
{
    private string[] _values = new string[10];

    // Public get, private set — external reads, internal writes only
    public string this[int index]
    {
        get => _values[index];
        private set => _values[index] = value;
    }

    public void Initialize()
    {
        // Only this method can write via the indexer
        this[0] = "connection_string";
        this[1] = "readonly_user";
    }
}

// Usage
var config = new ConfigStore();
config.Initialize();
Console.WriteLine(config[0]);  // connection_string
// config[0] = "new_value"; // Compile error: setter is private
Output
connection_string
Production Trap:
Don't confuse access modifier on the indexer declaration (e.g., protected on the whole indexer) with separate modifiers on get/set. One locks the entire indexer to a visibility scope, the other controls read vs write permissions independently. Mixing them up silently widens your attack surface.
Key Takeaway
Use access modifiers on get/set to enforce read-only or write-only contracts at compile time. Never rely on documentation to tell consumers they can't write.

Overloaded Indexers — Because One Dimension Isn't Enough

Your competitor pages show you can have multiple indexers on the same class. They don't tell you why you'd want to. Here's the real reason: mapping different key types to the same logical store feels natural to callers. A dictionary-style string indexer for user lookups, an int indexer for positional access — same class, two operations, zero ambiguity.

The compiler disambiguates by signature. Same as overloaded methods. So you can have this[string key] and this[int index] living side by side. The trick is keeping implementations consistent. If both indexers access the same backing store, you'd better make sure this["foo"] and this[0] don't contradict each other. That's a design smell — usually your indexers should map to different logical domains, not the same data with different keys.

Overloaded indexers are rare in the wild because they're easy to abuse. But when you need them (e.g., a matrix class with row/column access vs. flat index), they save you from writing two separate getter methods that do the same thing. Just don't make your team guess which one to call.

MultiIndexerMatrix.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// io.thecodeforge — csharp tutorial

public class SparseMatrix
{
    private Dictionary<(int, int), double> _store = new();

    // Row/column access
    public double this[int row, int col]
    {
        get => _store.TryGetValue((row, col), out var val) ? val : 0.0;
        set => _store[(row, col)] = value;
    }

    // Flat index access (row-major)
    public double this[int flatIndex]
    {
        get
        {
            int row = flatIndex / 100;
            int col = flatIndex % 100;
            return this[row, col];
        }
        set
        {
            int row = flatIndex / 100;
            int col = flatIndex % 100;
            this[row, col] = value;
        }
    }
}

// Usage
var matrix = new SparseMatrix();
matrix[0, 0] = 1.5;
Console.WriteLine(matrix[0]);  // Outputs 1.5
Output
1.5
Senior Shortcut:
When overloading indexers, always delegate the less-common signature to the more-common one (e.g., flat index delegates to row/col). That way you only have one source of truth for validation logic. If you don't, your code will rot when you add bounds checking in one method but forget the other.
Key Takeaway
Overloaded indexers are for different key domains, not aliases for the same data. When you must alias, delegate to one canonical indexer to avoid logic drift.

Multi-Dimensional Maps — Modeling Lookup Tables Like a Pro

Real-world data rarely fits a single key. You need a row and a column, a product and a region, a user and a timestamp. That's where multi-dimensional indexers come in — they turn your class into a proper matrix or lookup table.

The pattern is dead simple: accept two or more parameters and return a value. Under the hood you can use a nested Dictionary, a 2D array, or a real database connection. The caller never sees that mess — they just write store["EU", "Q3"] and get the number they need. This isn't just syntactic sugar; it's a contract that screams "this thing is a map, not a bag of methods."

Senior engineers reach for this when building configuration grids, permission tables, or any resource that has a natural two-axis structure. The WHY is obvious: it matches how humans think about tabular data. Don't make them call GetValue("EU", "Q3") when ["EU", "Q3"] says everything.

RegionalSalesMap.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// io.thecodeforge — csharp tutorial

using System.Collections.Generic;

public class RegionalSalesMap
{
    private readonly Dictionary<(string Region, string Quarter), decimal> _data = new();

    public decimal this[string region, string quarter]
    {
        get => _data.TryGetValue((region, quarter), out var val) ? val : 0m;
        set => _data[(region, quarter)] = value;
    }
}

// Usage:
var sales = new RegionalSalesMap();
sales["EU", "Q3"] = 142000.50m;
System.Console.WriteLine(sales["EU", "Q3"]);  // 142000.50
System.Console.WriteLine(sales["APAC", "Q1"]); // 0
Output
142000.50
0
Senior Shortcut:
Use value tuples as composite keys. They implement equality and hashing out of the box — no custom comparer needed. This pattern scales to 3, 4, or 5 keys without refactoring.
Key Takeaway
Multi-dimensional indexers turn your class into a lookup table. Callers write obj[a, b] — intuitive, testable, and production-ready.

Summing Up — When to Reach for Indexers (and When to Walk Away)

Indexers are a sharp tool. Use them when your class feels like a container — a list, a map, a matrix, a configuration store. The caller's mental model is "I have a key, give me the value." That's the sweet spot.

Walk away when the operation involves side effects, async work, or complex validation. If getting a value kicks off a database call or a calculation over thousands of records, use a named method like FetchAsync() or ComputeTotal(). An indexer that throws exceptions or blocks the thread is a design smell that junior devs leave for you to debug at 2 AM.

One more rule: keep the getter fast and idempotent. If you can't guarantee O(1) or at most O(log n), you're lying to the caller about the cost of your syntax. Respect the contract — indexers are for lookup, not for heavy lifting. That's the difference between a senior engineer and someone who just learned about this[].

IndexerDecisionGuide.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// io.thecodeforge — csharp tutorial

// DO use indexers for:
// - In-memory caches:   cache["userId"]
// - Config maps:        config["ConnectionString"]
// - Lookup tables:      matrix[row, col]

// DON'T use indexers for:
// - Database queries:   use FetchUser(id) not user[id]
// - Heavy computation:  use ComputeRiskScore() not risk[policyId]
// - Async operations:   GetAsync() never indexers

// Senior rule: if the getter can't return in < 1ms,
// don't call it with bracket syntax.
Output
(no output — decision guide)
Production Trap:
Never put async code in an indexer. C# indexers cannot be async. If you need async, expose a Task<T> GetAsync(key) method. The brackets lie about cost.
Key Takeaway
Indexers are for fast, sync, lookup-only operations. If your getter does work, use a method. Respect the caller's expectations.
● Production incidentPOST-MORTEMseverity: high

Null Reference Ambush in String-Indexed Config Store

Symptom
Unexpected NullReferenceException every time the application tries to read a specific configuration key after a feature toggle is removed from the store.
Assumption
The indexer returns null for missing keys — callers are expected to handle null. The internal team documented it, but new hires didn't read the docs.
Root cause
A method that previously always had a value for that key (because the feature toggle was present) suddenly received null after the toggle was cleaned up. The code assumed the value was non-null and used .ToUpper() on it.
Fix
Changed the indexer to throw KeyNotFoundException for missing keys when the key represents a required configuration. Added a TryGet(string key, out string? value) method for safe access. Updated all call sites to use TryGet or null-conditional operators.
Key lesson
  • Always document the return contract of your indexer clearly: null vs throw. Different callers need different guarantees.
  • Prefer throwing KeyNotFoundException when a missing key indicates a programming error — silent null propagates bugs.
  • Consider providing a TryGet method alongside your indexer for callers that want to avoid exceptions.
Production debug guideSymptom → Action guide for the most common indexer issues in production4 entries
Symptom · 01
IndexOutOfRangeException thrown from inside your class, not from the caller's code
Fix
Add bounds validation at the top of both get and set accessors. Throw ArgumentOutOfRangeException with a clear message before touching the backing array.
Symptom · 02
NullReferenceException on the return value of an indexer call
Fix
Verify the indexer returns null for missing keys. Use the null-conditional operator (obj?[key]) or null-coalescing (obj[key] ?? fallback) at the call site.
Symptom · 03
Setter is silently ignored — value doesn't persist
Fix
Confirm the indexer has a set accessor and it's not private. Check that the backing store is not readonly or replaced on each set.
Symptom · 04
Unexpected KeyNotFoundException when the key definitely exists
Fix
Check case sensitivity of your dictionary backing store. Use StringComparer.OrdinalIgnoreCase for case-insensitive string keys.
★ Indexer Debugging Cheat SheetQuick commands and checks when an indexer misbehaves in production
IndexOutOfRangeException on indexer access
Immediate action
Locate the indexer get accessor and add a bounds check before touching the backing store.
Commands
Check the backing array length vs the index value: inspect via debugger or log both sizes.
Use a conditional breakpoint on the indexer getter to catch the exact offending index.
Fix now
Add validation at the top of get and set and throw ArgumentOutOfRangeException with a clear message.
NullReferenceException when reading indexer result+
Immediate action
Determine if the indexer returns null for missing keys. Check the get accessor body.
Commands
Add a Debug.Assert or early throw for null inside the getter to capture the call stack.
Replace direct indexer calls with TryGet pattern if available.
Fix now
Change the getter to throw KeyNotFoundException for missing required keys, or add null-conditional operator at every call site.
Indexer vs Regular Property vs Method
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

1
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.
2
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.
3
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.
4
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

3 patterns
×

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. Production logs show the error origin as your internal method, not the caller's line.
Fix
Add bounds or key validity checks at the top of both accessors. Throw ArgumentOutOfRangeException or ArgumentException with a clear message before touching the backing store.
×

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?). Code reviews become slow because meaning is ambiguous.
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.
×

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. A bug manifests far from the actual indexer call, making it hard to trace.
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 PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Can you define an indexer in a C# interface? If so, what's the differenc...
Q02SENIOR
How does an indexer differ from a property in C#? When would you choose ...
Q03SENIOR
If a class has two indexers — one that takes an int and one that takes a...
Q01 of 03SENIOR

Can 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?

ANSWER
Yes, C# interfaces can declare indexers. You declare an indexer in an interface just like a property — with the 'this' keyword and parameter list. The difference: if you declare only a getter in the interface, any class implementing that interface must provide at least a getter. The class can optionally add a setter, but callers using the interface reference can't call the setter because it's not in the interface contract. This allows you to enforce read-only access at the contract level while still allowing the concrete class to have write access internally. This is a common pattern in dependency injection where you want to provide a read-only view to consumers but allow the implementing class to populate data.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Can a C# class have more than one indexer?
02
What is the difference between an indexer and a property in C#?
03
Does adding an indexer to a class automatically make it iterable with foreach?
04
Can an indexer be declared as static?
05
Can an indexer be async?
N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Everything here is grounded in real deployments.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's OOP in C#. Mark it forged?

7 min read · try the examples if you haven't

Previous
Operator Overloading in C#
9 / 10 · OOP in C#
Next
Covariance and Contravariance in C#