Indexers vs Properties: Choosing the Right Access Pattern in C#

Choosing the Right Access Pattern

If you've ever struggled deciding whether your type should expose data through properties or indexers, you're not alone. Both mechanisms provide controlled access to internal data, but they serve different purposes and communicate different intents to API consumers.

Properties work best for named, distinct values that belong to an object. Indexers shine when your type represents a collection or mapping where users naturally think in terms of keys or positions. Getting this choice right makes your API intuitive and self-documenting.

You'll learn when each pattern fits, how to implement both correctly, and where they overlap. By the end, you'll recognize the signals in your design that point to one approach over the other.

Properties for Named Values

Properties represent named characteristics of an object. When someone uses your type, they expect properties to describe what the object is or has. Each property has a distinct name that conveys its purpose.

Modern C# properties support auto-implementation, expression bodies, and init-only setters. These features make it easy to expose data with appropriate encapsulation while keeping your code concise.

Product.cs - Properties for distinct attributes
namespace ProductCatalog;

public class Product
{
    // Auto-implemented properties
    public int Id { get; init; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int StockCount { get; set; }

    // Computed property with expression body
    public bool InStock => StockCount > 0;

    // Property with validation in setter
    private decimal _discountPercent;
    public decimal DiscountPercent
    {
        get => _discountPercent;
        set
        {
            if (value < 0 || value > 100)
                throw new ArgumentOutOfRangeException(nameof(value));
            _discountPercent = value;
        }
    }

    // Computed property combining other properties
    public decimal FinalPrice => Price * (1 - DiscountPercent / 100);
}

Each property represents a distinct aspect of a product. Users access them by name: product.Name, product.Price. This naming makes the API self-documenting and discoverable through IntelliSense.

Indexers for Collection-Like Access

Indexers let you treat objects like arrays or dictionaries using bracket notation. When your type wraps or represents a collection of values, indexers make the API natural and concise. Users don't need to remember property names for values they access by key or position.

The indexer syntax uses this[type parameter] and supports get and set accessors just like properties. You can implement read-only indexers, write-only ones, or both directions.

Matrix.cs - Indexer for 2D access
namespace MathLib;

public class Matrix
{
    private readonly double[,] _data;

    public int Rows { get; }
    public int Cols { get; }

    public Matrix(int rows, int cols)
    {
        Rows = rows;
        Cols = cols;
        _data = new double[rows, cols];
    }

    // Indexer for element access
    public double this[int row, int col]
    {
        get
        {
            if (row < 0 || row >= Rows || col < 0 || col >= Cols)
                throw new IndexOutOfRangeException();
            return _data[row, col];
        }
        set
        {
            if (row < 0 || row >= Rows || col < 0 || col >= Cols)
                throw new IndexOutOfRangeException();
            _data[row, col] = value;
        }
    }
}

Users access matrix elements with matrix[0, 2] instead of calling methods like GetElement(0, 2). The bracket syntax matches how people mentally model matrices and makes the code read naturally.

String-Keyed Indexers for Dictionaries

Indexers aren't limited to integer indices. When your type behaves like a string-keyed dictionary, a string indexer makes the API feel like built-in collection types. This works well for configuration stores, property bags, and lookup tables.

You can combine indexers with underlying Dictionary or custom storage. The indexer provides controlled access while hiding implementation details from consumers.

Settings.cs - String-keyed indexer
namespace AppConfig;

public class Settings
{
    private readonly Dictionary<string, string> _values = new();

    // String-keyed indexer
    public string? this[string key]
    {
        get => _values.TryGetValue(key, out var value) ? value : null;
        set
        {
            ArgumentNullException.ThrowIfNull(key);
            if (value == null)
                _values.Remove(key);
            else
                _values[key] = value;
        }
    }

    public bool ContainsKey(string key) => _values.ContainsKey(key);

    public int Count => _values.Count;
}

This Settings class exposes a dictionary-like interface through the indexer. Users write settings["theme"] = "dark" which feels natural for configuration access. The indexer handles null values gracefully by removing entries.

Overloading Indexers by Type

You can provide multiple indexers with different parameter types, letting users access data in whatever way makes sense for their scenario. This flexibility improves usability when your type supports different lookup strategies.

Each indexer overload needs a unique signature based on parameter types. Common patterns include offering both positional and key-based access to the same underlying data.

Roster.cs - Multiple indexer overloads
namespace TeamManagement;

public class Roster
{
    private readonly List<Player> _players = new();

    // Positional indexer
    public Player this[int index]
    {
        get => _players[index];
        set => _players[index] = value;
    }

    // String-based indexer for lookup by name
    public Player? this[string name]
    {
        get => _players.FirstOrDefault(p =>
            p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
        set
        {
            var existing = _players.FirstOrDefault(p =>
                p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));

            if (existing != null && value != null)
            {
                var index = _players.IndexOf(existing);
                _players[index] = value;
            }
        }
    }

    public void Add(Player player) => _players.Add(player);
    public int Count => _players.Count;
}

public record Player(string Name, int Number, string Position);

The Roster class supports both roster[0] for positional access and roster["Smith"] for name-based lookup. Users pick whichever makes sense in context without needing separate methods for each access pattern.

Choosing the Right Approach

Choose properties when each value has a distinct semantic meaning. If consumers need to know what they're getting by name, properties provide clarity. Use properties for configuration values, object state, and calculated fields.

Choose indexers when your type represents a collection or mapping. If users think of your object as containing multiple values accessed by key or position, indexers match that mental model. Indexers work well for matrices, custom collections, lookup tables, and wrappers around dictionaries or arrays.

Sometimes you'll use both. A collection type might expose a Count property while providing indexers for element access. The key is matching the access pattern to how users conceptualize your type's data.

If unsure, ask whether users would naturally say "get the X" (property) or "get the item at X" (indexer). Properties name things; indexers locate things within a container.

Try It Yourself

Build a simple grade book that demonstrates both properties and indexers working together. You'll see how properties describe the object while indexers provide collection-like access to student grades.

Steps

  1. Create a new console project: dotnet new console -n GradeBookDemo
  2. Navigate to the directory: cd GradeBookDemo
  3. Replace Program.cs with the code below
  4. Update the project file as shown
  5. Execute with dotnet run
Program.cs
var gradeBook = new GradeBook("CS101");

gradeBook["Alice"] = 95;
gradeBook["Bob"] = 87;
gradeBook["Carol"] = 92;

Console.WriteLine($"Course: {gradeBook.CourseName}");
Console.WriteLine($"Students: {gradeBook.Count}");
Console.WriteLine($"Average: {gradeBook.Average:F1}");
Console.WriteLine($"Alice's grade: {gradeBook["Alice"]}");

class GradeBook
{
    private readonly Dictionary<string, int> _grades = new();

    // Properties describe the grade book
    public string CourseName { get; init; }
    public int Count => _grades.Count;
    public double Average => _grades.Count > 0
        ? _grades.Values.Average()
        : 0;

    public GradeBook(string courseName)
    {
        CourseName = courseName;
    }

    // Indexer provides collection-like access
    public int this[string studentName]
    {
        get => _grades.TryGetValue(studentName, out var grade) ? grade : 0;
        set => _grades[studentName] = value;
    }
}
GradeBookDemo.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

Output

Course: CS101
Students: 3
Average: 91.3
Alice's grade: 95

Notice how CourseName, Count, and Average are properties describing the grade book, while the string indexer provides natural access to individual student grades.

FAQ

When should I use an indexer instead of a property?

Use indexers when your type represents a collection or key-value store. If users naturally think of your object as a container that holds multiple values accessed by key or position, an indexer makes the API intuitive. Properties work better for named, distinct values.

Can I have multiple indexers in one class?

Yes, you can overload indexers by parameter type or count. For example, one indexer might accept an int for positional access while another takes a string for key-based access. Each must have a unique signature.

Do indexers support init accessors in C# 9+?

No, indexers cannot use init accessors. They support get and set only. If you need immutable initialization, consider collection expressions or builder patterns instead of indexers with init.

What's the performance difference between indexers and properties?

Both compile to methods, so raw performance is nearly identical. Indexers add parameter passing overhead but it's negligible. Choose based on API clarity, not micro-performance. If the backing store requires lookups, that dominates any indexer cost.

Back to Articles