Faster Data Access
If you've ever searched for a user by ID in a List with thousands of entries, you've felt the pain of linear searches. Each lookup scans the entire collection until it finds a match. When you're doing hundreds of lookups per request, this O(n) behavior turns into a performance bottleneck that slows your entire application.
This article shows how Dictionary provides O(1) average-case lookups using hash tables. You get near-instant access to values by key, regardless of collection size. This pattern replaces slow list searches with constant-time retrievals that scale to millions of items.
You'll learn basic dictionary operations, safe value retrieval with TryGetValue, when to use dictionaries versus lists, and common pitfalls with keys and thread safety. By the end, you'll recognize when dictionaries improve performance and how to use them correctly.
Creating and Using Dictionaries
A Dictionary<TKey, TValue> stores key-value pairs where each key is unique. You specify both the key type and value type when declaring it. Keys must implement GetHashCode and Equals properly, which value types and strings do by default.
Add items with the indexer syntax or the Add method. The indexer overwrites existing values, while Add throws if the key already exists. Choose based on whether duplicates are errors or expected updates.
// Create with explicit type
var userScores = new Dictionary<string, int>();
// Add using indexer (overwrites if key exists)
userScores["alice"] = 100;
userScores["bob"] = 85;
userScores["alice"] = 105; // Updates Alice's score
// Add using Add method (throws if key exists)
try
{
userScores.Add("carol", 90);
userScores.Add("carol", 95); // Throws ArgumentException
}
catch (ArgumentException ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
// Initialize with collection initializer
var productPrices = new Dictionary<string, decimal>
{
{ "Laptop", 999.99m },
{ "Mouse", 25.50m },
{ "Keyboard", 79.99m }
};
// Retrieve values
Console.WriteLine($"Laptop costs ${productPrices["Laptop"]}");
// Output:
// Error: An item with the same key has already been added. Key: carol
// Laptop costs $999.99
Collection initializers make dictionary setup concise. The indexer approach is safer when you're unsure if keys exist, while Add is better when duplicate keys indicate bugs you want to catch early.
Safe Value Retrieval with TryGetValue
Accessing a nonexistent key with the indexer throws KeyNotFoundException. In production code where keys might be missing, this creates unnecessary exception handling. TryGetValue checks for existence and retrieves the value in a single operation without exceptions.
TryGetValue returns true if the key exists and sets the out parameter to the value. If the key doesn't exist, it returns false and sets the out parameter to the default value for that type. This pattern is faster and cleaner than checking ContainsKey followed by indexer access.
var settings = new Dictionary<string, string>
{
{ "theme", "dark" },
{ "language", "en" },
{ "timezone", "UTC" }
};
// Bad: Indexer throws if key missing
try
{
var region = settings["region"]; // Throws KeyNotFoundException
}
catch (KeyNotFoundException)
{
Console.WriteLine("Region not configured");
}
// Better: ContainsKey + indexer (but does two lookups)
if (settings.ContainsKey("region"))
{
var region = settings["region"];
Console.WriteLine($"Region: {region}");
}
else
{
Console.WriteLine("Region not configured");
}
// Best: TryGetValue (single lookup, no exceptions)
if (settings.TryGetValue("region", out var regionValue))
{
Console.WriteLine($"Region: {regionValue}");
}
else
{
Console.WriteLine("Region not configured (using TryGetValue)");
}
// Provide default value when key missing
var region = settings.TryGetValue("region", out var r) ? r : "US";
Console.WriteLine($"Using region: {region}");
// Output:
// Region not configured
// Region not configured
// Region not configured (using TryGetValue)
// Using region: US
TryGetValue avoids both exceptions and double lookups. The single-lookup efficiency matters when you're checking thousands of keys in hot paths. This pattern should be your default for value retrieval.
Iterating and Modifying Dictionaries
Dictionaries are enumerable collections of KeyValuePair<TKey, TValue> items. You can iterate with foreach to access both keys and values. The order isn't guaranteed, so don't rely on insertion order for business logic.
You can modify values during iteration but can't add or remove keys. Changing the dictionary structure while iterating throws InvalidOperationException. Collect keys to remove first, then remove them in a separate loop.
var inventory = new Dictionary<string, int>
{
{ "apples", 50 },
{ "oranges", 30 },
{ "bananas", 0 },
{ "grapes", 25 }
};
// Iterate over key-value pairs
Console.WriteLine("Current Inventory:");
foreach (var kvp in inventory)
{
Console.WriteLine($"{kvp.Key}: {kvp.Value} units");
}
// Iterate using deconstruction (C# 7+)
foreach (var (product, quantity) in inventory)
{
Console.WriteLine($"{product}: {quantity}");
}
// Update values safely during iteration
foreach (var key in inventory.Keys.ToList())
{
inventory[key] += 10; // Restock everything by 10
}
// Remove items with zero stock
var keysToRemove = inventory.Where(kvp => kvp.Value == 0)
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in keysToRemove)
{
inventory.Remove(key);
}
Console.WriteLine("\nAfter Restocking and Cleanup:");
foreach (var (product, quantity) in inventory)
{
Console.WriteLine($"{product}: {quantity} units");
}
// Output:
// Current Inventory:
// apples: 50 units
// oranges: 30 units
// bananas: 0 units
// grapes: 25 units
// After Restocking and Cleanup:
// apples: 60 units
// oranges: 40 units
// grapes: 35 units
Notice how we call ToList on Keys before removing items. This creates a snapshot of keys, preventing modification errors. The deconstruction syntax makes iteration more readable by extracting key and value directly.
Choosing Appropriate Key Types
Keys must remain stable after insertion. If a key's hash code changes, the dictionary can't find it anymore. Strings, numbers, and enums work perfectly as keys because they're immutable. Records with init-only properties are also safe.
For custom types as keys, override GetHashCode and Equals consistently. Items that are equal must have the same hash code. Use ValueTuple for composite keys when you need multiple fields to identify an item.
// String keys (most common)
var userCache = new Dictionary<string, User>();
// Numeric keys
var ordersByIdvar ordersByIdvar = new Dictionary<int, Order>();
// Enum keys
var statusCounts = new Dictionary<OrderStatus, int>();
// Composite keys using ValueTuple
var salesByRegionAndMonth = new Dictionary<(string Region, int Month), decimal>
{
{ ("West", 1), 50000m },
{ ("West", 2), 52000m },
{ ("East", 1), 48000m },
{ ("East", 2), 51000m }
};
// Look up by composite key
var westJanSales = salesByRegionAndMonth[("West", 1)];
Console.WriteLine($"West region Jan sales: ${westJanSales}");
// Record as key (immutable by design)
record ProductKey(string Category, string Sku);
var productDetails = new Dictionary<ProductKey, ProductInfo>
{
{ new ProductKey("Electronics", "LAP-001"), new ProductInfo("Laptop", 999.99m) },
{ new ProductKey("Electronics", "MOU-002"), new ProductInfo("Mouse", 25.50m) }
};
var key = new ProductKey("Electronics", "LAP-001");
if (productDetails.TryGetValue(key, out var info))
{
Console.WriteLine($"Found: {info.Name} at ${info.Price}");
}
// Output:
// West region Jan sales: $50000
// Found: Laptop at $999.99
record ProductInfo(string Name, decimal Price);
enum OrderStatus { Pending, Shipped, Delivered, Cancelled }
record User(int Id, string Name);
record Order(int Id, decimal Total);
ValueTuple keys work well for multi-field lookups without creating new classes. Records give you proper equality semantics automatically, making them ideal for domain-specific keys. Avoid mutable classes as keys unless you carefully implement equality and never modify key instances after insertion.
Try It Yourself
Build a small console app that demonstrates dictionary basics including TryGetValue and iteration.
Steps
- Scaffold:
dotnet new console -n DictionaryDemo
- Change directory:
cd DictionaryDemo
- Replace Program.cs content
- Update .csproj
- Run:
dotnet run
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
var stocks = new Dictionary<string, decimal>
{
{ "AAPL", 178.50m },
{ "MSFT", 412.30m },
{ "GOOGL", 142.75m },
{ "AMZN", 178.25m }
};
Console.WriteLine("Stock Prices:");
foreach (var (symbol, price) in stocks)
{
Console.WriteLine($"{symbol}: ${price}");
}
// Safe lookup with TryGetValue
string[] lookups = { "AAPL", "TSLA", "MSFT" };
Console.WriteLine("\nLookup Results:");
foreach (var symbol in lookups)
{
if (stocks.TryGetValue(symbol, out var price))
{
Console.WriteLine($"{symbol}: ${price}");
}
else
{
Console.WriteLine($"{symbol}: Not found");
}
}
// Update and add
stocks["AAPL"] = 180.00m; // Update existing
stocks["TSLA"] = 245.80m; // Add new
Console.WriteLine($"\nUpdated AAPL: ${stocks["AAPL"]}");
Console.WriteLine($"Added TSLA: ${stocks["TSLA"]}");
Run result
Stock Prices:
AAPL: $178.50
MSFT: $412.30
GOOGL: $142.75
AMZN: $178.25
Lookup Results:
AAPL: $178.50
TSLA: Not found
MSFT: $412.30
Updated AAPL: $180.00
Added TSLA: $245.80