Skip to content

Collections

Collections are data types that provide structures for storing and accessing multiple elements. Here are the most important collection types in C#.


  • Namespace: System
  • Fixed size: yes
  • Ordering: Elements have a well-defined position (called Index)
  • Index access: ✅ Fast (O(1))
  • When tu use: When you know the exact number of elements in advance, and performance is critical.

Pros: Fast access, memory-efficient.
⚠️ Cons: Size cannot be changed once created.

int[] numbers = new int[3];
numbers[0] = 10;
numbers[1] = 20;
numbers[2] = 30;
foreach (int n in numbers)
{
Console.WriteLine(n);
}

  • Namespace: System.Collections.Generic
  • Fixed size: No (can grow when elements are inserted)
  • Ordering: Maintains insertion order. (Elements are stored in array under the hood)
  • Index access: ✅ Fast (O(1))
  • When to use: Default choice for most dynamic collections.

Pros: Easy to use, fast lookup by index, good performance for adding/removing at the end.
⚠️ Cons: Inserting/removing in the middle can be costly (O(n)).

using System;
using System.Collections.Generic;
var list = new List<int> { 1, 2, 3 };
list.Add(4);
list.Remove(2);
foreach (int n in list)
{
Console.WriteLine(n);
}

  • Namespace: System.Collections.Generic
  • Fixed size: No
  • Ordering: Unordered (no guaranteed order).
  • Duplicates: ❌ Not allowed.
  • Access: Fast membership checks (O(1)).
  • When to use: When you need a collection of unique elements and fast lookup, but order doesn’t matter.

Pros: Very fast Add, Remove, and Contains.
⚠️ Cons: No index access, no guaranteed order.

using System;
using System.Collections.Generic;
var set = new HashSet<string>();
set.Add("apple");
set.Add("banana");
set.Add("apple"); // ignored, duplicate
foreach (var item in set)
{
Console.WriteLine(item);
}

  • Namespace: System.Collections.Generic
  • Fixed size: No
  • Ordering: No guaranteed order
  • Access: Fast key lookup (O(1))
  • When to use: When you want to associate keys with values for fast lookup.

Pros: Very fast lookup by key.
⚠️ Cons: No indexing by position.

using System;
using System.Collections.Generic;
var dict = new Dictionary<string, int>();
dict["Alice"] = 25;
dict["Bob"] = 30;
Console.WriteLine(dict["Alice"]);
// iterate
foreach (var kvp in dict)
{
Console.WriteLine($"{kvp.Key}: {kvp.Value}");
}
// lookup
var age = dict["Alice"];

When accessing a specific value in a dictionary you need to know its key. Note, that the lookup might fail, if there is no value for a specific key:

var dict = new Dictionary<string, int>();
dict["Alice"] = 25;
var result = dict["Bob"]; // KeyNotFoundException

Of course you can wrap the lookup in a try-catch block, but using exception handling for such cases is not recommended, because it adds a lot of overhead and can slow down your application.

What you can often use instead is the Try Pattern. Some types offer methods that are named Try.... For example the Dictionary<T> type offers a method called TryGetValue. Usually these methods follow the same pattern:

  • They perform the action specified after the Try, for example getting the value from the dictionary.
  • Instead of offering the result as a return value, the return a boolean value indicating, if the action was successful.
  • The result value is offered as an out Parameter instead. That is a special type of parameter that allows extracting information from the method call, similar to a return value.
var dict = new Dictionary<string, int>();
dict["Alice"] = 25;
// use the try pattern
int result;
bool keyExists = TryGetValue("Bob", out result); // the keyword out is needed
// if the call was successful:
// - keyExists will be true
// - result will hold the value corresponding to the key
if (keyExists)
{
// you can access result here
}
else
{
// no corresponding value found
}

or shorter:

var dict = new Dictionary<string, int>();
dict["Alice"] = 25;
// use the try pattern
if (TryGetValue("Bob", out var result)) // you can create a new variable for the result inline
{
// you can access result here
}
else
{
// no corresponding value found
}

  • Namespace: System.Collections.Generic
  • Purpose: Provides read-only iteration over a collection.
  • Index access: No (just iteration)
  • When to use: When you only need to read data in a foreach loop and don’t need modification.

Pros: Works with foreach, LINQ, and is the lowest common denominator for collections.
⚠️ Cons: No add/remove/index operations.

using System;
using System.Collections.Generic;
IEnumerable<int> enumerable = new List<int> { 1, 2, 3 };
foreach (var n in enumerable)
{
Console.WriteLine(n);
}

  • Namespace: System.Collections.Generic
  • Extends: IEnumerable<T>
  • Purpose: Adds count, Add, Remove, and Contains methods.
  • When to use: When you need a modifiable collection, but don’t need index-based access. Often used when accessing database tables from code.

Pros: More capabilities than IEnumerable.
⚠️ Cons: No direct indexing.

using System;
using System.Collections.Generic;
ICollection<string> names = new List<string>();
names.Add("Alice");
names.Add("Bob");
Console.WriteLine(names.Count);
foreach (var name in names)
{
Console.WriteLine(name);
}

  • Namespace: System.Collections.Generic
  • Extends: ICollection<T> and IEnumerable<T>
  • Purpose: Adds indexing, Insert, and RemoveAt.
  • When to use: When you need the flexibility of a list and a common interface type.

Pros: Full list functionality via interface.
⚠️ Cons: No dictionary-style key lookup.

using System;
using System.Collections.Generic;
IList<int> numbers = new List<int>();
numbers.Add(10);
numbers.Add(20);
numbers.Insert(1, 15); // Insert at index 1
foreach (var n in numbers)
{
Console.WriteLine(n);
}

  • Namespace: System.Linq
  • Extends: IEnumerable<T>
  • Purpose: Supports deferred execution and building Linq queries that can be translated (e.g., to SQL).
  • When to use: When querying data from external sources like databases, where the query is not executed in memory.

Pros: Can build complex queries; executed by the data provider (e.g., SQL server).
⚠️ Cons: Only makes sense with LINQ providers; don’t use for in-memory lists.

using System;
using System.Linq;
using System.Collections.Generic;
IQueryable<int> queryable = new List<int> { 1, 2, 3, 4, 5 }.AsQueryable();
var evenNumbers = queryable.Where(n => n % 2 == 0);
foreach (var n in evenNumbers)
{
Console.WriteLine(n);
}

Type / InterfaceFixed SizeModifiableIndexedKey LookupDeferred QueryTypical Use Case
T[] (Array)✅ Yes❌ No✅ Yes❌ No❌ NoFast fixed-size storage
List<T>❌ No✅ Yes✅ Yes❌ No❌ NoGeneral-purpose dynamic list
HashSet<T>❌ No✅ Yes❌ No✅ Yes❌ NoUnique elements
Dictionary<TKey,TValue>❌ No✅ Yes❌ No✅ Yes❌ NoKey-value storage
IEnumerable<T>N/A❌ No❌ No❌ No✅ LINQRead-only iteration
ICollection<T>N/A✅ Yes❌ No❌ No❌ NoAbstract collection manipulation
IList<T>N/A✅ Yes✅ Yes❌ No❌ NoGeneric list handling
IQueryable<T>N/A❌ Usually❌ No❌ No✅ YesDatabase and LINQ provider queries

👉 Practical rule of thumb:

  • Use List<T> for most in-memory dynamic lists.
  • Use Dictionary<TKey, TValue> for key-value lookups.
  • Use IEnumerable<T> for read-only or LINQ pipelines.
  • Use IQueryable<T> when querying databases.
  • Use arrays only when the size is fixed or performance is critical.
  • Use ICollection<T> / IList<T> as abstractions to make your APIs more flexible.