
Getting Started with System.Collections and Immutable Types in C# and .NET – Part 1
Introduction
When writing C# code, most of us tend to stick with the basics List<T>
, arrays, maybe a Dictionary<TKey, TValue>
when needed. They’re familiar, flexible, and get the job done in many scenarios.
But the .NET framework provides much more.
The System.Collections
namespace (and its generic and specialized variants) offers a wide range of collection types designed to solve specific problems from optimized access patterns to thread safety and immutability.
Understanding what’s available and when to use it can help you write better code: cleaner, more maintainable, and often more performant.
In this first part, we’ll go through:
- The core interfaces behind most collections (
IEnumerable<T>
,ICollection<T>
,IList<T>
) - Common and essential collection types like arrays, lists, queues, stacks, and dictionaries
- The trade-offs and practical use cases for each
Let’s explore what the System.Collections
family really has to offer.
The Core Interfaces Behind Most Collections
Most collection types in .NET are built on a small set of interfaces that define how elements can be accessed, added, removed, or iterated.
Understanding these interfaces IEnumerable<T>
, ICollection<T>
, and IList<T>
helps you write more flexible and reusable code.
IEnumerable<T> The Starting Point
IEnumerable<T>
is the most basic interface for collections. It allows you to iterate through elements using foreach
and supports LINQ queries.
But it’s important to remember: iteration is the only thing it supports, and LINQ queries on IEnumerable<T>
are executed lazily (deferred execution).
What does deferred execution mean?
When you write a LINQ query or return an IEnumerable<T>
, the code doesn’t always run immediately. Instead, it waits until you actually try to access the data. This behavior is called deferred execution.
In other words, the code inside your method (like a yield return
, a database call, or a LINQ filter) won’t run until you start looping over the collection or force it to evaluate using .ToList()
or .ToArray()
.
Example: Deferred execution in action
public static IEnumerable<int> GetNumbers()
{
Console.WriteLine("Generating numbers...");
yield return 1;
yield return 2;
yield return 3;
}
var numbers = GetNumbers(); // No execution yet
Console.WriteLine("Before foreach");
foreach (var number in numbers)
{
Console.WriteLine($"Number: {number}");
}
Output:
Before foreach
Generating numbers...
Number: 1
Number: 2
Number: 3
ICollection – Basic Manipulation
ICollection<T>
extends IEnumerable<T>
by adding methods for modifying the collection, such as:
Add(T item)
Remove(T item)
Clear()
Contains(T item)
Count
Example: Adding and removing elements
ICollection<string> fruits = new List<string>();
fruits.Add("Apple");
fruits.Add("Banana");
Console.WriteLine(fruits.Contains("Apple")); // True
Console.WriteLine(fruits.Count); // 2
fruits.Remove("Apple");
Console.WriteLine(fruits.Count); // 1
Output:
True
2
1
IList – Indexed Access
IList<T>
extends ICollection<T>
and adds index-based access (list[0]
) and positioning methods like Insert()
and RemoveAt()
.
Example: Working with indexes
IList<int> numbers = new List<int> { 10, 20, 30 };
numbers.Insert(1, 15); // [10, 15, 20, 30]
numbers.RemoveAt(2); // [10, 15, 30]
foreach (var n in numbers)
{
Console.WriteLine(n);
}
Output:
10
15
30
Common and Essential Collection Types
Now that we’ve seen the core interfaces behind collections in .NET, it’s time to look at the most commonly used collection types that implement them.
Most C# developers use List<T>
, arrays, and Dictionary<TKey, TValue>
every day. These types are part of the System.Collections.Generic
namespace and cover a wide range of typical use cases: storing items in order, mapping keys to values, or processing data in stacks or queues.
While they may seem simple, each collection has different performance characteristics and ideal scenarios. Choosing the right one can make your code faster, cleaner, and easier to maintain.
In the next sections, we’ll explore:
- The differences between
Array
andList<T>
- How to use
Dictionary<TKey, TValue>
for fast key-based lookups - The structure and behavior of
Stack<T>
andQueue<T>
- Concrete examples with expected outputs to help you get started
Let’s begin with one of the most common choices: array versus list.
Array vs List
When working with collections in C#, one of the most common decisions is whether to use an array
or a List<T>
. While both store ordered elements and support index-based access, they behave quite differently under the hood.
Array
An array has a fixed size. Once you create it, you cannot add or remove elements. This makes it efficient in terms of memory and access speed, but much less flexible.
int[] numbers = new int[3] { 1, 2, 3 };
Console.WriteLine(numbers[0]); // 1
// numbers[3] = 4; // This would throw an exception (IndexOutOfRangeException)
Arrays are ideal when:
- You know the exact number of elements in advance
- You want the best possible performance for indexed access
- You don’t need to resize or modify the collection after creation
List<T>
A List<T>
is a dynamic collection that grows automatically as you add elements. It’s the go-to collection type for most .NET applications because it provides a rich set of methods for manipulating data.
List<int> numbers = new List<int> { 1, 2, 3 };
numbers.Add(4);
Console.WriteLine(numbers[3]); // 4
You can also insert, remove, sort, search, and filter elements with built-in methods:
numbers.Insert(1, 10); // [1, 10, 2, 3, 4]
numbers.Remove(2); // Removes the first occurrence of 2
bool contains = numbers.Contains(3); // true
Summary
Feature | Array | List<T> |
---|---|---|
Size | Fixed | Dynamic (auto-growing) |
Index Access | Yes | Yes |
Performance | Slightly better | Slightly slower due to overhead |
Flexibility | Low | High |
Methods | Limited (length, indexing) | Rich API (Add, Remove, Insert…) |
Use an array when your collection size is known and fixed, and you care about speed or memory usage.
Use List<T>
when you need flexibility and an easier API to work with.
Dictionary<TKey, TValue>
A Dictionary<TKey, TValue>
is a collection that stores key-value pairs. It allows you to associate a unique key with a specific value, and it provides fast lookups, inserts, and updates.
It’s one of the most commonly used collections in .NET when you need to retrieve data based on a unique identifier — like a product ID, a username, or a configuration name.
Basic usage
You can create a dictionary by specifying the key and value types, then use the indexer syntax ([key]
) to add or update entries.
var inventory = new Dictionary<string, int>();
inventory["apple"] = 5;
inventory["banana"] = 3;
Console.WriteLine(inventory["apple"]); // 5
If you try to access a key that doesn’t exist, you’ll get a KeyNotFoundException
. To avoid this, you can use ContainsKey
or TryGetValue
.
if (inventory.ContainsKey("orange"))
{
Console.WriteLine(inventory["orange"]);
}
else
{
Console.WriteLine("Key not found.");
}
// Or using TryGetValue
if (inventory.TryGetValue("banana", out int quantity))
{
Console.WriteLine($"Bananas in stock: {quantity}");
}
Why use Dictionary<TKey, TValue>
- Lookup by key is very fast in most cases
- In technical terms, accessing a value by key has an average time complexity of O(1)
This means that the time it takes to find a value does not grow with the size of the dictionary it’s nearly instant, even with many items. - Keys must be unique
- Useful when you want to model data as a mapping between identifiers and values
Summary
Use a dictionary when:
- You need to retrieve values by a unique key
- The order of elements doesn’t matter
- You want better performance than searching a list manually
Stack<T> (LIFO)
A Stack<T>
is a collection that works on a Last-In, First-Out (LIFO) principle. The last item you add is the first one to be removed. This makes stacks useful for scenarios like:
- Undo operations
- Navigating backward in a browser history
- Parsing expressions
Basic usage
You use Push()
to add an item to the top of the stack, and Pop()
to remove and return the item on top. If you only want to look at the top item without removing it, you can use Peek()
.
var history = new Stack<string>();
history.Push("Home");
history.Push("About");
history.Push("Contact");
Console.WriteLine(history.Pop()); // Contact
Console.WriteLine(history.Peek()); // About
Console.WriteLine(history.Count); // 2
Output:
Contact
About
2
Summary
Use a stack when:
- You need to reverse the order of operations
- You want to process elements in the opposite order they were added
- You’re implementing an undo/redo system or a recursive algorithm
Stack<T>
is simple but powerful, and it’s part of the System.Collections.Generic
namespace like most core collection types.
Queue<T> (FIFO)
A Queue<T>
is a collection that follows the First-In, First-Out (FIFO) principle. The first item you add is the first one to come out just like people waiting in line.
Queues are useful in many scenarios, such as:
- Scheduling tasks
- Processing background jobs
- Event handling systems
- Message queues
Basic usage
You add items using Enqueue()
and remove them using Dequeue()
. You can also use Peek()
to look at the next item without removing it.
var tasks = new Queue<string>();
tasks.Enqueue("Task1");
tasks.Enqueue("Task2");
tasks.Enqueue("Task3");
Console.WriteLine(tasks.Dequeue()); // Task1
Console.WriteLine(tasks.Peek()); // Task2
Console.WriteLine(tasks.Count); // 2
Output:
Task1
Task2
2
Summary
Use a queue when:
- You need to process items in the order they were added
- You’re building systems where order matters (e.g. job queues)
- You want a simple way to model pipelines or messaging patterns
Like Stack<T>
, the Queue<T>
is efficient and easy to use, and it’s part of the System.Collections.Generic
namespace
Summary: Choosing the Right Collection
Type | Use Case | Key Properties |
---|---|---|
Array | Fixed-size storage with fast access | High performance, no resizing |
List<T> | General-purpose dynamic collection | Auto-resizing, rich API |
Dictionary<TKey, TValue> | Fast lookup by key | Key-value mapping, no duplicate keys |
Stack<T> | Reversing order, undo actions | Last-in, first-out (LIFO) |
Queue<T> | Ordered task or message processing | First-in, first-out (FIFO) |
Choosing the right collection can improve your application’s performance, simplify your code, and help prevent bugs related to data access or structure.
Collection Trade-offs and Practical Guidelines
Now that you’ve seen the most common collection types in C#, let’s take a step back and talk about when to use each one and how to avoid some typical beginner mistakes.
Knowing a type exists is useful. Knowing when and why to use it is even better.
When to Use What
Here’s a simplified guide to help you decide:
- Use an
array
when performance matters and the size of the collection is fixed. - Use a
List<T>
when you need a flexible, resizable collection that supports indexing. - Use a
Dictionary<TKey, TValue>
when you need fast lookups by key. - Use a
Queue<T>
when you need to process elements in the order they arrive. - Use a
Stack<T>
when the last element added should be the first to be removed.
Common Mistakes to Avoid
Even experienced developers occasionally misuse collection types. Here are a few pitfalls to watch for:
- Using
List<T>
to enforce uniqueness.
If you want to avoid duplicates, use aHashSet<T>
(covered in Part 2). - Returning
IEnumerable<T>
from a method without realizing it’s deferred.
If you return a query without materializing it (e.g. with.ToList()
), it may be executed multiple times, which can lead to performance issues or unexpected results. - Modifying a collection while iterating it.
This often throws exceptions. If you need to modify a list while looping through it, consider using afor
loop and working with indices — or collect the changes and apply them afterward.
Tips for Cleaner and More Flexible Code
- Use interfaces for method parameters and return types
PreferIEnumerable<T>
,IReadOnlyList<T>
, orIDictionary<TKey, TValue>
to make your code more testable and easier to refactor. - Avoid unnecessary allocation
If you’re creating a list just to loop over it once, consider usingyield return
andIEnumerable<T>
. - Don’t optimize too early
Focus first on readability and correctness. You can always switch to a more specialized collection later if needed.
Example: How to Use .NET Collections in a Simple Order Processing System
Disclaimer: This example is purely for educational purposes. There are better ways to write code and applications that can optimize this example. Use this as a starting point for learning, but always strive to follow best practices and improve your implementation.
All the code is available in this GitHub repository under the collections-part1
branch. This repository will help you apply the concepts discussed in the article in a concrete way
Prerequisites
Before starting, make sure you have the following installed:
- .NET SDK: Download and install the .NET SDK if you haven’t already.
- Visual Studio Code (VSCode): Install Visual Studio Code for a lightweight code editor.
- C# Extension for VSCode: Install the C# extension for VSCode to enable C# support.
Getting Started
To try the example on your own machine, follow these steps:
- Open Visual Studio Code
- Create a new folder for your project, for example:
OrderProcessingDemo
- Open the folder in VSCode
- Open the terminal and run:
dotnet new console -n OrderProcessingDemo
cd OrderProcessingDemo
This will create a new .NET Console App
called OrderProcessingDemo
.
- Replace the contents of the generated
Program.cs
file with the code below - Run the application with:
dotnet run
This will execute the example and show the output in your terminal.
This simple console application simulates a basic order processing system using core .NET collection types. It’s designed to show how List<T>
, Dictionary<TKey, TValue>
, Queue<T>
, Stack<T>
, and IEnumerable<T>
can work together in a real-world scenario.
Here’s what the example covers:
- Creating a list of orders using
List<T>
- Storing and retrieving orders by ID using a
Dictionary
- Simulating a queue of pending orders using
Queue<T>
- Undoing a change using
Stack<T>
as an undo buffer - Filtering orders using
IEnumerable<T>
with LINQ

The goal is to demonstrate how each collection type fits naturally into different aspects of a simple, practical workflow.

Program.cs
Console.WriteLine("=== Order Processing System ===");
// 1. List<Order> - holds all orders
List<Order> orders = new()
{
new Order { Id = "ORD001", Products = new List<string> { "Keyboard", "Mouse" } },
new Order { Id = "ORD002", Products = new List<string> { "Monitor" } },
new Order { Id = "ORD003", Products = new List<string> { "Laptop", "Headset" } }
};
// 2. Dictionary<string, Order> - fast access by ID
var orderLookup = new Dictionary<string, Order>();
foreach (var order in orders)
{
orderLookup[order.Id] = order;
}
Console.WriteLine("ORD002 has " + orderLookup["ORD002"].Products.Count + " product(s).");
// 3. Queue<Order> - simulate a processing queue
Queue<Order> processingQueue = new(orders);
var currentOrder = processingQueue.Dequeue();
Console.WriteLine($"Processing order: {currentOrder.Id}");
// 4. Stack<Order> - simulate undo history
Stack<Order> undoStack = new();
currentOrder.Status = "Shipped";
undoStack.Push(currentOrder);
Console.WriteLine($"Order {currentOrder.Id} status updated to {currentOrder.Status}");
// Undo operation
var lastModified = undoStack.Pop();
lastModified.Status = "Pending";
Console.WriteLine($"Undo: Order {lastModified.Id} status reverted to {lastModified.Status}");
// 5. IEnumerable<Order> - filter and display
IEnumerable<Order> pendingOrders = orders.Where(o => o.Status == "Pending");
Console.WriteLine("Pending orders:");
foreach (var pendingOrder in pendingOrders)
{
Console.WriteLine($"- {pendingOrder.Id}");
}
class Order
{
public string Id { get; set; }
public List<string> Products { get; set; } = new();
public string Status { get; set; } = "Pending";
}

Conclusion
Working with collections is a core part of writing software in C#. While List<T>
, arrays, and dictionaries cover many use cases, understanding how they behave under the hood and when to use them can make a big difference in the clarity, performance, and reliability of your code.
If you’re just getting started with .NET, mastering these core types is an important step. Focus on choosing the right tool for the job, and don’t be afraid to explore beyond the default options.
In the next article, we’ll explore collection types that are often overlooked but incredibly useful: immutable collections, concurrent types, and other specialized structures that can help in multithreaded or high-performance scenarios.
See you in Part 2.
If you think your friends or network would find this article useful, please consider sharing it with them. Your support is greatly appreciated.
Thanks for reading!
🚀 Discover CodeSwissKnife, your all-in-one, offline toolkit for developers!
Click to explore CodeSwissKnife 👉