Collections in C#: Types, Examples, and How to Choose the Right One
TL;DR: Collections in C# are classes that store groups of objects and grow or shrink as you add and remove items, unlike fixed-size arrays. Most of them live in System.Collections.Generic, which gives you type-safe classes such as List<T>, Dictionary<TKey, TValue>, HashSet<T>, Queue<T>, and Stack<T>. The older non-generic classes in System.Collections, like ArrayList and Hashtable, still work but are kept mainly for legacy code. Pick a collection by how you need to access the data: by position, by key, by uniqueness, or by order of arrival.

Almost every real program works with groups of data: a cart full of products, a list of users, a queue of print jobs. You can group items in two ways in C#: with arrays, which have a fixed size, or with collections, which resize themselves and come with built-in methods to add, remove, search, and sort. From a data structures point of view, collections give you ready-made lists, stacks, queues, hash tables, and trees, so you don't have to build them yourself.

This tutorial on collections in C# (sometimes written as collections in C Sharp) walks through every major collection type with runnable code and real output. You'll learn the difference between generic and non-generic collections, how the collection interfaces fit together, and how to pick the right collection for a job.

AI-Powered Full Stack Developer ProgramExplore Program
Want a Top Software Development Job? Start Here!

What Are Collections in C#?

Collections in C# are classes built to store, organize, and change groups of related objects, letting you add, remove, find, and manage items without managing memory or array sizes yourself. The .NET libraries include collection classes that support stacks, queues, lists, hash tables, and sets, and most of them implement the same small group of interfaces, so they behave in familiar ways.

Collection classes handle memory allocation for you as the number of items changes, and many of them give you index-based or key-based access. Here's a simple example with List<T>, the collection you'll use most often:

using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        // A collection grows as you add items
        List<string> languages = new List<string>();
        languages.Add("C#");
        languages.Add("Java");
        languages.Add("Python");

        Console.WriteLine("Count: " + languages.Count);
        Console.WriteLine("First: " + languages[0]);
        Console.WriteLine("Has Java? " + languages.Contains("Java"));

        languages.Remove("Java");
        Console.WriteLine("After removing Java: " + string.Join(", ", languages));
    }
}

Output:

Count: 3
First: C#
Has Java? True
After removing Java: C#, Python

Collection Expressions (C# 12 and Later)

From C# 12 onward, you can create most collections with square brackets instead of calling a constructor and Add() repeatedly. The spread element .. copies the items of one collection into another.

using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        // Collection expressions (C# 12 and later)
        List<string> backend = ["C#", "Java"];
        List<string> frontend = ["JavaScript", "TypeScript"];

        // The spread element (..) copies items from another collection
        List<string> all = [.. backend, .. frontend, "SQL"];
        int[] numbers = [1, 2, 3];

        Console.WriteLine(string.Join(", ", all));
        Console.WriteLine(string.Join(", ", numbers));
    }
}

Output:

C#, Java, JavaScript, TypeScript, SQL
1, 2, 3

AI-Powered Full Stack Developer ProgramExplore Program
Here's How to Land a Top Software Developer Job

Array vs Collection in C#

Arrays and collections both hold groups of items, but they suit different jobs. An array's length is fixed when you create it. A collection like List<T> grows as you add items.

using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        // An array has a fixed size set when it is created
        int[] scores = new int[3];
        scores[0] = 85;
        scores[1] = 92;
        scores[2] = 78;
        // scores[3] = 60; would throw IndexOutOfRangeException

        // A List<T> resizes itself as items are added
        List<int> scoreList = new List<int> { 85, 92, 78 };
        scoreList.Add(60);
        scoreList.Add(71);

        Console.WriteLine("Array length: " + scores.Length);
        Console.WriteLine("List count: " + scoreList.Count);
    }
}

Output:

Array length: 3
List count: 5

Feature

Array

Collection (such as List<T>)

Size

Fixed when created

Grows and shrinks automatically

Namespace

System (System.Array)

System.Collections.Generic and others

Adding or removing items

Not supported; create a new array

Add(), Insert(), Remove(), Clear()

Built-in helpers

Few (through the Array class)

Many: Contains(), Find(), Sort(), IndexOf()

Data shapes

Indexed elements only

Lists, key-value pairs, sets, stacks, queues, linked lists

Performance

Slightly faster, with no resizing overhead

Small overhead when the collection resizes

Best for

Fixed-size data you know up front

Data whose size changes at run time

Use an array when the size is known and won't change, such as the 12 months of the year. Use a collection everywhere else.

Also Read: Arrays in C#

Types of Collections in C#

Collections in C# are usually grouped by the namespace they come from:

  1. Generic collections (System.Collections.Generic): type-safe classes such as List<T>, Dictionary<TKey, TValue>, and HashSet<T>. These are the default choice for new code.
  2. Non-generic collections (System.Collections): older classes such as ArrayList and Hashtable that store everything as object.
  3. Concurrent collections (System.Collections.Concurrent): thread-safe classes such as ConcurrentDictionary<TKey, TValue> for code that runs on several threads at once.
  4. Immutable collections (System.Collections.Immutable): collections such as ImmutableList<T> that can't be changed after creation.

.NET also has System.Collections.Specialized, with a few strongly typed classes such as StringCollection, and, since .NET 8, System.Collections.Frozen, with read-only collections tuned for fast lookups. The rest of this tutorial covers generic collections first, then the non-generic ones you'll meet in older code.

Java Certification TrainingENROLL NOW
Dive Deep Into Java Core Concepts

Generic Collections in C#

A generic collection is type-safe: you tell it what type it holds, and the compiler stops you from adding anything else. You don't have to cast items when you read them, and value types such as int are stored without boxing, which makes generic collections faster. Generic collection classes are in the System.Collections.Generic namespace.

List<T>

A generic List stores items in order and lets you reach any of them by index. You specify the data type when you create it, and every element must be of that type. Internally, List<T> uses an array that it replaces with a bigger one when it fills up.

using System;
using System.Collections.Generic;

namespace genericList
{
    class Program
    {
        static void Main(string[] args)
        {
            List<int> GenericList = new List<int>();
            GenericList.Add(30);
            GenericList.Add(60);
            GenericList.Add(90);
            GenericList.Add(120);

            foreach (int x in GenericList)
            {
                Console.WriteLine(x);
            }
        }
    }
}

Output:

30
60
90
120

Dictionary<TKey, TValue>

A dictionary stores data as key-value pairs, and you specify both data types up front. Each key must be unique, and looking up a value by its key is very fast. Use TryGetValue() when a key might be missing, because reading a missing key with the indexer throws a KeyNotFoundException.

using System;
using System.Collections.Generic;

namespace genericDictionary
{
    class Program
    {
        static void Main(string[] args)
        {
            Dictionary<int, string> GenericDictionary = new Dictionary<int, string>();
            GenericDictionary.Add(1, "Soda");
            GenericDictionary.Add(2, "Burger");
            GenericDictionary.Add(3, "Fries");
            GenericDictionary.Add(4, "Onion Rings");

            foreach (KeyValuePair<int, string> kvp in GenericDictionary)
            {
                Console.WriteLine(kvp.Key + " " + kvp.Value);
            }
        }
    }
}

Output:

1 Soda
2 Burger
3 Fries
4 Onion Rings

SortedList<TKey, TValue>

A sorted list also stores key-value pairs, but it keeps them sorted by key in ascending order. As with the generic dictionary, you specify the types of its keys and values beforehand. Notice in the output that the items come back in alphabetical order of their keys, not in the order they were added.

using System;
using System.Collections.Generic;

namespace genericSortedList
{
    class Program
    {
        static void Main(string[] args)
        {
            SortedList<string, string> GenericSortedList = new SortedList<string, string>();
            GenericSortedList.Add("American", "Burger");
            GenericSortedList.Add("Lime", "Soda");
            GenericSortedList.Add("French", "Fries");
            GenericSortedList.Add("Onion", "Rings");

            foreach (KeyValuePair<string, string> kvp in GenericSortedList)
            {
                Console.WriteLine(kvp.Key + " " + kvp.Value);
            }
        }
    }
}

Output:

American Burger
French Fries
Lime Soda
Onion Rings

Stack<T>

A stack stores values in LIFO (last in, first out) order. Push() adds an item to the top, Pop() removes and returns the top item, and Peek() returns the top item without removing it. When you loop over a stack with foreach, items come back from top to bottom, so the last item pushed is printed first.

using System;
using System.Collections.Generic;

namespace genericStack
{
    class Program
    {
        static void Main(string[] args)
        {
            Stack<string> steak = new Stack<string>();
            steak.Push("Rare");
            steak.Push("Medium Rare");
            steak.Push("Medium");
            steak.Push("Well done");

            foreach (string s in steak)
            {
                Console.WriteLine(s);
            }
        }
    }
}

Output:

Well done
Medium
Medium Rare
Rare

Queue<T>

A queue stores values in FIFO (first in, first out) order, so items come out in the order they went in. Enqueue() adds an item to the back, Dequeue() removes and returns the item at the front, and Peek() reads the front item without removing it. If you need to add and remove items at both ends, you need a deque instead, a role LinkedList<T> can play in C#.

using System;
using System.Collections.Generic;

namespace genericQueue
{
    class Program
    {
        static void Main(string[] args)
        {
            Queue<string> GenericQueue = new Queue<string>();
            GenericQueue.Enqueue("Mark");
            GenericQueue.Enqueue("Bill");
            GenericQueue.Enqueue("Xavier");
            GenericQueue.Enqueue("Michael");

            foreach (string s in GenericQueue)
            {
                Console.WriteLine(s);
            }
        }
    }
}

Output:

Mark
Bill
Xavier
Michael

HashSet<T>

A HashSet<T> stores unique values with no particular order. Adding a duplicate does nothing, and Add() returns false to tell you so. Checking whether an item exists is very fast, which makes a hash set the right tool for removing duplicates or testing membership. It also supports set operations such as UnionWith(), IntersectWith(), and ExceptWith().

using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        HashSet<string> tags = new HashSet<string>();
        Console.WriteLine(tags.Add("csharp"));   // True
        Console.WriteLine(tags.Add("dotnet"));   // True
        Console.WriteLine(tags.Add("csharp"));   // False: duplicate ignored

        Console.WriteLine("Count: " + tags.Count);
        Console.WriteLine("Contains dotnet? " + tags.Contains("dotnet"));

        HashSet<string> other = new HashSet<string> { "dotnet", "azure" };
        tags.IntersectWith(other);
        Console.WriteLine("Common tags: " + string.Join(", ", tags));
    }
}

Output:

True
True
False
Count: 2
Contains dotnet? True
Common tags: dotnet

LinkedList<T>

A LinkedList<T> is a doubly linked list: each item points to the one before and after it. Inserting or removing an item is quick once you have a reference to its node, because nothing else has to shift. The trade-off is that there's no index, so reaching the 500th item means walking through the first 499.

using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        LinkedList<string> playlist = new LinkedList<string>();
        playlist.AddLast("Song B");
        playlist.AddLast("Song C");
        playlist.AddFirst("Song A");

        LinkedListNode<string> nodeB = playlist.Find("Song B");
        playlist.AddAfter(nodeB, "Song B2");
        playlist.Remove("Song C");

        foreach (string song in playlist)
        {
            Console.WriteLine(song);
        }
    }
}

Output:

Song A
Song B
Song B2

SortedDictionary<TKey, TValue> and SortedSet<T>

SortedDictionary<TKey, TValue> works like a dictionary but keeps its keys in sorted order. It's built on a tree, so inserts and removals are faster than in SortedList<TKey, TValue> when you have lots of data, while SortedList uses less memory. SortedSet<T> is the sorted version of HashSet<T>: it keeps unique values in order and can give you the smallest and largest values directly.

using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        SortedDictionary<string, int> stock = new SortedDictionary<string, int>();
        stock["Mango"] = 40;
        stock["Apple"] = 25;
        stock["Banana"] = 60;

        foreach (KeyValuePair<string, int> item in stock)
        {
            Console.WriteLine(item.Key + " " + item.Value);
        }

        SortedSet<int> ids = new SortedSet<int> { 42, 7, 19, 7, 3 };
        Console.WriteLine("Sorted unique IDs: " + string.Join(", ", ids));
        Console.WriteLine("Min: " + ids.Min + ", Max: " + ids.Max);
    }
}

Output:

Apple 25
Banana 60
Mango 40
Sorted unique IDs: 3, 7, 19, 42
Min: 3, Max: 42

Java Certification TrainingENROLL NOW
Master Core Java 8 Concepts, Java Servlet, & More!

Non-Generic Collections in C#

Non-generic collections are the original collection classes from the first version of .NET, before generics existed. They live in the System.Collections namespace and store every item as an object, so they can hold any data type. That flexibility costs you type safety and speed, and Microsoft recommends generic collections for new code. You'll still see these classes in older codebases, so it helps to know how they work.

ArrayList

An ArrayList is a dynamic array: its size changes as you add and remove items. It works much like List<T>, except that it can mix types in one collection, as the example below does with a string, an integer, and a date.

using System;
using System.Collections;

namespace nonGenericArrayList
{
    class Program
    {
        static void Main(string[] args)
        {
            ArrayList NonGenericArrayList = new ArrayList();
            string str = "Like, Share, Subscribe";
            int x = 11;
            DateTime d = DateTime.Parse("3-dec-1998");

            NonGenericArrayList.Add(str);
            NonGenericArrayList.Add(x);
            NonGenericArrayList.Add(d);

            foreach (object o in NonGenericArrayList)
            {
                Console.WriteLine(o);
            }
        }
    }
}

Output:

Like, Share, Subscribe
11
12/03/1998 00:00:00

The date line depends on the culture settings of the machine running the code. On a computer set to US English, for example, it prints 12/3/1998 12:00:00 AM.

Hashtable

A Hashtable stores key-value pairs and finds values by comparing the hash codes of their keys. It works much like the generic Dictionary<TKey, TValue>, but keys and values are stored as objects.

using System;
using System.Collections;

namespace nonGenericHashTable
{
    class Program
    {
        static void Main(string[] args)
        {
            Hashtable NonGenericHashTable = new Hashtable();
            NonGenericHashTable.Add(1, "Soda");
            NonGenericHashTable.Add(2, "Burger");
            NonGenericHashTable.Add(3, "Fries");
            NonGenericHashTable.Add(4, "Onion Rings");

            foreach (DictionaryEntry h in NonGenericHashTable)
            {
                Console.WriteLine(h.Key + " " + h.Value);
            }
        }
    }
}

Output:

4 Onion Rings
3 Fries
2 Burger
1 Soda

A Hashtable doesn't keep items in the order you added them, so the order you see when you loop over it depends on the hash codes and can vary. This run printed the items in reverse order. If you need a predictable order, use a sorted collection.

SortedList

The non-generic SortedList works like the generic SortedList<TKey, TValue>, except that you don't specify the data types. It keeps its key-value pairs sorted by key.

using System;
using System.Collections;

namespace nonGenericSortedList
{
    class Program
    {
        static void Main(string[] args)
        {
            SortedList NonGenericSortedList = new SortedList();
            NonGenericSortedList.Add("American", "Burger");
            NonGenericSortedList.Add("Lime", "Soda");
            NonGenericSortedList.Add("French", "Fries");
            NonGenericSortedList.Add("Onion", "Rings");

            foreach (DictionaryEntry d in NonGenericSortedList)
            {
                Console.WriteLine(d.Key + " " + d.Value);
            }
        }
    }
}

Output:

American Burger
French Fries
Lime Soda
Onion Rings

Stack

The non-generic Stack is a LIFO (last in, first out) collection, just like the generic Stack<T>. The last item pushed is the first one returned.

using System;
using System.Collections;

namespace nonGenericStack
{
    class Program
    {
        static void Main(string[] args)
        {
            Stack steak = new Stack();
            steak.Push("Rare");
            steak.Push("Medium Rare");
            steak.Push("Medium");
            steak.Push("Well done");

            foreach (object o in steak)
            {
                Console.WriteLine(o);
            }
        }
    }
}

Output:

Well done
Medium
Medium Rare
Rare

Queue

The non-generic Queue represents a first-in, first-out collection of objects. Use it when you need items back in the order they were added. It offers the same methods as the generic Queue<T>.

using System;
using System.Collections;

namespace nonGenericQueue
{
    class Program
    {
        static void Main(string[] args)
        {
            Queue NonGenericQueue = new Queue();
            NonGenericQueue.Enqueue("Mark");
            NonGenericQueue.Enqueue("Bill");
            NonGenericQueue.Enqueue("Xavier");
            NonGenericQueue.Enqueue("Michael");

            foreach (object o in NonGenericQueue)
            {
                Console.WriteLine(o);
            }
        }
    }
}

Output:

Mark
Bill
Xavier
Michael
Learn 45+ in-demand full-stack development skills and tools, including Frontend Development, Backend Development, Version Control and Collaboration, Database Management, and AI-Assisted Development, with our Full Stack Developer Course.

Generic vs Non-Generic Collections in C#

The two families can look alike, but they behave differently once something goes wrong. In the example below, the ArrayList happily accepts a string where the code expects numbers, and the mistake only shows up when the program runs. The List<int> version would refuse to compile the same mistake.

using System;
using System.Collections;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        // Non-generic: anything goes in, and you cast on the way out
        ArrayList mixed = new ArrayList();
        mixed.Add(10);       // the int is boxed into an object
        mixed.Add("twenty"); // compiles without complaint

        int total = 0;
        try
        {
            foreach (object item in mixed)
            {
                total += (int)item; // fails at run time on "twenty"
            }
        }
        catch (InvalidCastException)
        {
            Console.WriteLine("ArrayList: InvalidCastException at run time");
        }

        // Generic: the type is checked when you compile
        List<int> numbers = new List<int> { 10, 20 };
        // numbers.Add("twenty"); would not compile
        int sum = 0;
        foreach (int n in numbers)
        {
            sum += n; // no cast, no boxing
        }
        Console.WriteLine("List<int> sum: " + sum);
    }
}

Output:

ArrayList: InvalidCastException at run time
List<int> sum: 30

Point of comparison

Generic collections

Non-generic collections

Namespace

System.Collections.Generic

System.Collections

Type safety

Checked at compile time

Not checked; errors appear at run time

Casting

Not needed when reading items

Needed, since items come back as object

Boxing of value types

None

Every int, double, and so on is boxed

Performance

Faster

Slower because of boxing and casting

Examples

List<T>, Dictionary<TKey, TValue>, HashSet<T>

ArrayList, Hashtable, non-generic SortedList

Recommended for new code

Yes

No; mainly for legacy code

C# Collections Hierarchy and Interfaces

A set of interfaces ties together collection classes. Each interface adds a bit more ability than the one it builds on:

  • IEnumerable<T> is the base. It lets you loop over items with foreach and use LINQ, and nothing more.
  • ICollection<T> extends IEnumerable<T> and adds Count, Add(), Remove(), Clear(), and Contains().
  • IList<T> extends ICollection<T> with index-based access, through the indexer, Insert(), and RemoveAt().
  • ISet<T> extends ICollection<T> with set operations such as UnionWith().
  • IDictionary<TKey, TValue> extends ICollection<KeyValuePair<TKey, TValue>> with key-based access.

Here's how the main classes map to them:

Class

Main interface it implements

List<T>

IList<T>

Dictionary<TKey, TValue>, SortedDictionary<TKey, TValue>, SortedList<TKey, TValue>

IDictionary<TKey, TValue>

HashSet<T>, SortedSet<T>

ISet<T>

LinkedList<T>

ICollection<T>

Stack<T>, Queue<T>

IEnumerable<T> and IReadOnlyCollection<T> (not ICollection<T>, since you add items with Push() or Enqueue())

There are also read-only interfaces, IReadOnlyCollection<T>, IReadOnlyList<T>, and IReadOnlyDictionary<TKey, TValue>, which you can use to hand out a collection without letting the caller change it. The non-generic classes follow a matching pattern with IEnumerable, ICollection, IList, and IDictionary.

IEnumerable vs ICollection in C#

Writing methods against an interface instead of a concrete class makes them more flexible. In this example, Total() accepts any IEnumerable<int>, so it works with a list, a set, and an array alike. AddBonus() needs to add an item and read the count, so it asks for an ICollection<int>.

using System;
using System.Collections.Generic;

class Program
{
    // Accepts any collection that can be looped over
    static int Total(IEnumerable<int> values)
    {
        int sum = 0;
        foreach (int v in values)
        {
            sum += v;
        }
        return sum;
    }

    // Accepts any collection that can report its size and be changed
    static void AddBonus(ICollection<int> values)
    {
        values.Add(100);
        Console.WriteLine("Count after bonus: " + values.Count);
    }

    static void Main()
    {
        List<int> list = new List<int> { 10, 20, 30 };
        HashSet<int> set = new HashSet<int> { 5, 15 };
        int[] array = { 1, 2, 3 };

        Console.WriteLine("List total: " + Total(list));
        Console.WriteLine("Set total: " + Total(set));
        Console.WriteLine("Array total: " + Total(array));

        AddBonus(list);
        AddBonus(set);
    }
}

Output:

List total: 60
Set total: 20
Array total: 6
Count after bonus: 4
Count after bonus: 3

IEnumerable<T>

ICollection<T>

What it lets you do

Loop over items

Loop, count, add, remove, and check for items

Count property

No (LINQ's Count() loops through every item)

Yes

Add and remove

No

Yes, through Add(), Remove(), and Clear()

Typical use

Method parameters that only read data, and LINQ query results

Methods that need to change the collection or know its size

A good rule is to ask for the simplest interface your method needs. If a method only reads items, accept IEnumerable<T>.

AI-Powered Full Stack Developer ProgramExplore Program
Kickstart Your Full Stack Career With Us!

Concurrent and Immutable Collections in C#

The standard generic collections aren't safe to change from several threads at once. If your code runs work in parallel, use the classes in System.Collections.Concurrent, such as ConcurrentDictionary<TKey, TValue>, ConcurrentQueue<T>, ConcurrentStack<T>, and ConcurrentBag<T>, which handle the locking for you.

Immutable collections take a different approach. Instead of protecting a shared collection, they never change: every "change" returns a new collection and leaves the original as it was. That makes them naturally thread-safe and useful for data that should stay fixed once it's built.

using System;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        // ConcurrentDictionary: safe to update from many threads at once
        ConcurrentDictionary<string, int> hits = new ConcurrentDictionary<string, int>();
        Parallel.For(0, 1000, i =>
        {
            hits.AddOrUpdate("home", 1, (key, current) => current + 1);
        });
        Console.WriteLine("Home page hits: " + hits["home"]);

        // ImmutableList: every change returns a new list
        ImmutableList<string> original = ImmutableList.Create("A", "B");
        ImmutableList<string> updated = original.Add("C");
        Console.WriteLine("Original: " + string.Join(", ", original));
        Console.WriteLine("Updated: " + string.Join(", ", updated));
    }
}

Output:

Home page hits: 1000
Original: A, B
Updated: A, B, C

With a plain Dictionary<string, int>, the parallel loop above could lose updates or throw an exception. The concurrent version counts all 1,000 hits correctly.

Expand your programming expertise with Simplilearn's Java Certification Training. Build a strong foundation in Core Java, Java EE, Spring, Hibernate, and multithreading while gaining practical experience through 35+ coding exercises and hands-on web projects.

Which Collection Should You Use in C#?

Start from how you need to reach your data:

If you need to

Use

Store items in order and access them by position

List<T>

Look up values by a unique key

Dictionary<TKey, TValue>

Look up values by key and keep keys sorted

SortedDictionary<TKey, TValue> (frequent changes) or SortedList<TKey, TValue> (mostly reads, less memory)

Keep unique values and test membership quickly

HashSet<T>

Keep unique values in sorted order

SortedSet<T>

Process items first in, first out

Queue<T>

Process items last in, first out

Stack<T>

Insert or remove often in the middle of a sequence

LinkedList<T>

Share a collection safely across threads

A class from System.Collections.Concurrent

Make sure data never changes after creation

A class from System.Collections.Immutable

Store a fixed number of items

An array

Speed is the other deciding factor. These are the typical costs of common operations, where n is the number of items:

Operation

Typical cost

List<T>.Add()

O(1) on average, O(n) when the list has to resize

List<T> access by index

O(1)

List<T>.Contains()

O(n)

Dictionary<TKey, TValue> add or lookup

O(1) on average

HashSet<T>.Add() or Contains()

O(1) on average

SortedDictionary<TKey, TValue> and SortedSet<T> add or lookup

O(log n)

SortedList<TKey, TValue> lookup

O(log n)

SortedList<TKey, TValue> add

O(n), because items have to shift

Stack<T>.Push() and Queue<T>.Enqueue()

O(1) on average

LinkedList<T> add or remove at a known node

O(1)

This is why searching a large List<T> with Contains() is slow while checking a HashSet<T> is fast, even though both answer the same question.

Benefits of Collections in C#

Collections in C# give you several advantages over managing arrays by hand:

  • They resize automatically, so you don't need to know the number of items in advance.
  • They come with tested methods for adding, removing, searching, and sorting, which saves you from writing and debugging that code yourself.
  • Generic collections catch type errors at compile time and avoid boxing, so they're both safer and faster than the non-generic ones.
  • You get a ready-made data structure for almost any need: lists, key-value maps, sets, stacks, queues, linked lists, and sorted versions of most of them.
  • Because collections share common interfaces, the same foreach loops and LINQ queries work across all of them.
  • Specialized namespaces cover thread safety (System.Collections.Concurrent), unchangeable data (System.Collections.Immutable), and fast read-only lookups (System.Collections.Frozen).
Wondering how Software Engineers reach senior and leadership roles? Explore the skills, technologies, salary growth, and career progression behind one of the world's most in-demand jobs with this software engineer roadmap.

C# Collections Interview Questions

These questions come up often in C# interviews and test whether you understand how collections behave, not just what they're called.

1. What happens when you add a duplicate key to a Dictionary?

Calling Add() with a key that already exists throws an ArgumentException. Assigning through the indexer, as in dict[key] = value, overwrites the existing value instead. If you want to add a key only when it's new, TryAdd() returns false rather than throwing.

2. How does List<T> grow when it runs out of space?

A List<T> stores its items in an internal array. When that array is full, the list creates a new array twice the size and copies the items over. That's why Add() is usually O(1) but occasionally O(n). If you know roughly how many items you'll store, pass a capacity to the constructor, as in new List<int>(1000), to skip the repeated copying.

3. When would you choose LinkedList<T> over List<T>?

Choose LinkedList<T> when you frequently insert or remove items in the middle of a sequence and already hold a reference to the node involved. In most other cases, List<T> is faster in practice, because its items sit next to each other in memory and can be reached by index.

4. Why can't you modify a List inside a foreach loop?

Adding or removing items while a foreach loop is running invalidates the loop's enumerator, so .NET throws an InvalidOperationException. Use a method such as RemoveAll(), loop backward with a for loop, or loop over a copy instead.

using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        List<int> numbers = new List<int> { 1, 2, 3, 4, 5, 6 };

        try
        {
            foreach (int n in numbers)
            {
                if (n % 2 == 0) numbers.Remove(n);
            }
        }
        catch (InvalidOperationException)
        {
            Console.WriteLine("Changing a list inside foreach throws InvalidOperationException");
        }

        // Safe alternative
        numbers.RemoveAll(n => n % 2 == 0);
        Console.WriteLine("Odd numbers left: " + string.Join(", ", numbers));
    }
}

Output:

Changing a list inside foreach throws InvalidOperationException
Odd numbers left: 1, 3, 5

5. How do you share a collection without letting other code change it?

You have three main options. list.AsReadOnly() returns a read-only wrapper, but changes made to the original list still show through it. Exposing the collection as IReadOnlyList<T> or IReadOnlyCollection<T> hides the methods that change it. For data that must never change, use an immutable collection, or a frozen collection such as FrozenDictionary<TKey, TValue> in .NET 8 and later.

Key Takeaways

  • Collections in C# store groups of objects and resize automatically, which makes them more flexible than fixed-size arrays.
  • Generic collections from System.Collections.Generic are type-safe and fast, and they're the default choice for new code.
  • Non-generic collections such as ArrayList and Hashtable store everything as object and are mainly found in legacy code.
  • Interfaces such as IEnumerable<T>, ICollection<T>, and IList<T> connect the collection classes, so methods written against them work with many collection types.
  • Choose a collection by how you access your data: List<T> for position, Dictionary<TKey, TValue> for keys, HashSet<T> for uniqueness, and Queue<T> or Stack<T> for order of processing.

Conclusion

Collections in C# are one of the first tools you'll reach for in almost any program. Once you know what each one is good at, choosing between a list, a dictionary, a set, a queue, or a stack becomes a quick decision, and your code gets simpler and faster as a result. Start with the generic collections, keep the non-generic ones in mind for older code, and try changing the examples above to see how each collection behaves.

Want to keep building your development skills? The Full Stack Developer Course teaches you to build complete web applications, where organizing and transforming data with collections is a daily task.

FAQs

1. Is ArrayList obsolete in C#?

ArrayList isn't formally marked obsolete, and it still works in current versions of .NET. Microsoft recommends List<T> instead, because it's type-safe and avoids boxing, so ArrayList mostly survives in older code.

2. What is the difference between Dictionary and Hashtable in C#?

Dictionary<TKey, TValue> is generic, so its keys and values have fixed types and value types aren't boxed. Hashtable is non-generic and stores everything as an object. Reading a missing key also behaves differently: Hashtable returns null, while the Dictionary indexer throws a KeyNotFoundException, which is why TryGetValue() is the usual way to read from a dictionary.

3. Do C# collections allow duplicate and null values?

It depends on the collection. List<T> allows both duplicates and null (for reference types). HashSet<T> ignores duplicates and can hold a single null. A Dictionary<TKey, TValue> requires unique, non-null keys, but its values can be null.

4. How do you sort a List in C#?

Call list.Sort() to sort the list in place using the default order for its type. To sort by a custom rule, pass a comparison, such as people.Sort((a, b) => a.Age.CompareTo(b.Age)). If you want a sorted copy and want the original left alone, use LINQ's OrderBy() and call ToList() on the result.

5. How do you convert an array to a List in C#?

Pass the array to the constructor with new List<int>(numbers), or call numbers.ToList() after adding using System.Linq;. From C# 12 onward, you can also write List<int> list = [.. numbers];. To go the other way, call ToArray() on the list.

About the Author

Vaibhav KhandelwalVaibhav Khandelwal

Vaibhav Khandelwal is a proactive tech geek who's always on the edge of learning new technologies. He is well versed in competitive programming and possesses sound knowledge of web development. He likes to read fictional and sci-fi novels and likes to play strategy games like chess

View More
  • Acknowledgement
  • PMP, PMI, PMBOK, CAPM, PgMP, PfMP, ACP, PBA, RMP, SP, OPM3 and the PMI ATP seal are the registered marks of the Project Management Institute, Inc.
  • *All trademarks are the property of their respective owners and their inclusion does not imply endorsement or affiliation.
  • Career Impact Results vary based on experience and numerous factors.