Skip to main content

Collections: List, Dictionary, HashSet, Queue, Stack – C# | Lesson 47

Published / updated

Introduction​

Class 10 has 1,006 students. An array from Lesson 33 has a fixed size, so it cannot grow when a new student joins, and finding one student means checking every position until you reach them.

.NET offers a family of collections, each designed for one way of working with many objects. This lesson uses the five you will meet in every C# codebase, on real data exported from SchoolDb.

By the end of this lesson, you will be able to:

  • Use List<T>, and explain how it differs from an array.
  • Find items in one step with Dictionary<TKey, TValue>.
  • Keep only unique values with HashSet<T>.
  • Process items in order with Queue<T> and Stack<T>.
  • Choose the right collection, and avoid the errors each one can throw.

Why this matters​

From Module 9 onward, almost every method returns or receives a collection: all students in a section, all marks of an exam, all payments of an invoice. Choosing the wrong collection does not just look untidy; it can make a fast screen slow.

In this lesson, finding each of the 1,006 students in a List took about 24 milliseconds. The same work in a Dictionary took 0.1 milliseconds. With 10,000 students and many users at once, that difference decides whether a page loads instantly or times out.

The concept​

Example: the five collections​

CollectionHoldsBest forSchool office version
List<T>Items in order, by positionMost everyday listsThe class register: entries in order
Dictionary<TKey, TValue>Values found by a unique keyFast lookup by Id or codeThe filing cabinet, sorted by admission number
HashSet<T>Unique values, no order"Have I seen this before?"The list of stamps already collected
Queue<T>First in, first outProcessing in arrival orderParents waiting at the fee counter
Stack<T>Last in, first outUndo, going backA pile of papers: the last one placed is on top

The <T> means "of some type": List<Student> is a list of students, List<decimal> a list of marks. Lesson 48 explains this syntax, called generics, in full.

Example: List versus array​

Array, Student[]List, List<Student>
SizeFixed when createdGrows and shrinks as needed
Add an itemNot possiblelist.Add(student)
Countarray.Lengthlist.Count
Remove an itemNot possiblelist.Remove(student)
Speed of reading by positionFastestAlmost the same

A List<T> keeps an array inside. When it fills up, it creates a bigger one and copies the items across, so adding stays fast. Use arrays for fixed data, such as the five days of a week, and lists for everything that changes.

Example: Dictionary keys​

A dictionary stores pairs: a key and a value. Each key appears at most once, and looking up by key takes about the same time whether the dictionary holds ten items or ten thousand.

OperationCodeWhen the key is missing
Adddict.Add(key, value)Throws if the key already exists
Readdict[key]Throws KeyNotFoundException
Safe readdict.TryGetValue(key, out value)Returns false
Checkdict.ContainsKey(key)Returns false

Admission numbers, receipt numbers, and ISBNs make perfect keys: unique, short, and already used to find things in the real school.

Apply it to the SMS​

Step 1: the data files​

Module 8 needs real data before Module 9 connects to SQL Server. Two files, exported from SchoolDb with sqlcmd, now live in the repository's data folder:

FileRowsColumns
data/class10-students.csv1,006AdmissionNumber, FirstName, LastName, DateOfBirth, Gender, Section, RollNumber
data/class10-marks.csv12,012AdmissionNumber, Exam, Subject, MaxMarks, MarksObtained (empty when absent)

The console project copies them next to the program when it builds, using one setting in SchoolApp.Console.csproj. The marks file is used from Lesson 50.

The files are a snapshot, not a live connection. If SchoolDb changes, the files do not; that is exactly the gap Module 9 closes, when C# starts reading the database directly.

Step 2: collection navigation properties​

Lesson 44 promised the "one to many" links. Student and Section now each hold a list of enrollments:

Example
public List<Enrollment> Enrollments { get; set; } = new List<Enrollment>();

The list starts empty instead of null, so code can always loop over it safely. An empty class list means "no students yet", which is true, rather than "unknown", which would crash.

Step 3: loading the file​

src/SchoolApp.Console/DataFiles.cs
using System.Globalization;
using SchoolApp.Core.Entities;
using SchoolApp.Core.Enums;

// Lesson 47: reads the Class 10 data exported from SchoolDb (the data folder).
// Module 9 replaces these files with real database queries.
static class DataFiles
{
private static string PathOf(string fileName) => Path.Combine(AppContext.BaseDirectory, "data", fileName);

// One Enrollment per line, linked to its Student and its Section in both directions.
public static List<Enrollment> LoadClass10Enrollments()
{
List<Enrollment> enrollments = new List<Enrollment>();
Dictionary<string, Section> sections = new Dictionary<string, Section>();
bool isHeader = true;

foreach (string line in File.ReadLines(PathOf("class10-students.csv")))
{
if (isHeader)
{
isHeader = false;
continue;
}

// AdmissionNumber,FirstName,LastName,DateOfBirth,Gender,Section,RollNumber
string[] f = line.Split(',');
DateOnly dateOfBirth = DateOnly.ParseExact(f[3], "yyyy-MM-dd", CultureInfo.InvariantCulture);
Student student = new Student(f[0], f[1], f[2], dateOfBirth, (Gender)f[4][0]);

if (!sections.TryGetValue(f[5], out Section? section))
{
section = new Section { Name = f[5], ClassId = 10 };
sections.Add(f[5], section);
}

Enrollment enrollment = new Enrollment
{
AcademicYearId = 2,
RollNumber = short.Parse(f[6], CultureInfo.InvariantCulture),
Student = student,
Section = section
};
student.Enrollments.Add(enrollment);
section.Enrollments.Add(enrollment);
enrollments.Add(enrollment);
}

return enrollments;
}
}

File.ReadLines reads the file one line at a time. Each line is split at the commas, and the parts become a Student, a Section, and an Enrollment, linked in both directions.

The dates and numbers are parsed with CultureInfo.InvariantCulture. Without it, a computer set to a language that writes decimals with a comma, as many European languages do, would misread 92.00.

Step 4: the program​

Show src/SchoolApp.Console/Program.cs
src/SchoolApp.Console/Program.cs
// Lesson 47: collections, on the real Class 10 data exported from SchoolDb.
using System.Diagnostics;
using SchoolApp.Core.Entities;

// 1. List<T>: a list that grows. 1,006 enrollments loaded from data/class10-students.csv.
List<Enrollment> classTen = DataFiles.LoadClass10Enrollments();
Console.WriteLine($"Class 10 enrollments: {classTen.Count}; first: {classTen[0].Student!.FullName}; last: {classTen[^1].Student!.FullName}");

// 2. Dictionary<TKey, TValue>: find a student by admission number in one step.
Dictionary<string, Student> byAdmissionNumber = new Dictionary<string, Student>();
foreach (Enrollment enrollment in classTen)
{
byAdmissionNumber.Add(enrollment.Student!.AdmissionNumber, enrollment.Student);
}

if (byAdmissionNumber.TryGetValue("ADM-2017-0003", out Student? kabir))
{
Console.WriteLine($"ADM-2017-0003 is {kabir.FullName}, section {kabir.Enrollments[0].Section!.Name}");
}
Console.WriteLine($"ADM-2099-0001 exists: {byAdmissionNumber.ContainsKey("ADM-2099-0001")}");

// 3. The collection navigation property: the class list of section A.
Section sectionA = classTen[0].Section!;
Console.Write($"10-{sectionA.Name} ({sectionA.Enrollments.Count} students):");
foreach (Enrollment enrollment in sectionA.Enrollments)
{
Console.Write($" {enrollment.RollNumber}.{enrollment.Student!.FirstName}");
}
Console.WriteLine();

// 4. HashSet<T>: only unique values. How many different family names in Class 10?
HashSet<string> lastNames = new HashSet<string>();
foreach (Enrollment enrollment in classTen)
{
lastNames.Add(enrollment.Student!.LastName);
}
Console.WriteLine($"Different last names: {lastNames.Count}; adding 'Sharma' again added a new one: {lastNames.Add("Sharma")}");

// 5. Queue<T>: first in, first out, like the fee counter on the first day of term.
Queue<string> feeCounter = new Queue<string>();
feeCounter.Enqueue("Suresh Patel");
feeCounter.Enqueue("Harpreet Singh");
feeCounter.Enqueue("Venkat Iyer");
Console.WriteLine($"Served first: {feeCounter.Dequeue()}; next in line: {feeCounter.Peek()}; still waiting: {feeCounter.Count}");

// 6. Stack<T>: last in, first out, like undo while typing marks.
Stack<decimal> typedMarks = new Stack<decimal>();
typedMarks.Push(92m);
typedMarks.Push(85m);
typedMarks.Push(670m); // a typing mistake
Console.WriteLine($"Undo removes {typedMarks.Pop()}; last mark now {typedMarks.Peek()}");

// 7. Searching a List versus a Dictionary: find every student by admission number.
Stopwatch timer = Stopwatch.StartNew();
int foundInList = 0;
foreach (Enrollment target in classTen)
{
foreach (Enrollment candidate in classTen)
{
if (candidate.Student!.AdmissionNumber == target.Student!.AdmissionNumber)
{
foundInList++;
break;
}
}
}
double listMs = timer.Elapsed.TotalMilliseconds;

timer.Restart();
int foundInDictionary = 0;
foreach (Enrollment target in classTen)
{
if (byAdmissionNumber.ContainsKey(target.Student!.AdmissionNumber))
{
foundInDictionary++;
}
}
double dictionaryMs = timer.Elapsed.TotalMilliseconds;
Console.WriteLine($"List: found {foundInList} in {listMs:F2} ms; Dictionary: found {foundInDictionary} in {dictionaryMs:F2} ms");
Output
Class 10 enrollments: 1006; first: Aarav Sharma; last: Aisha Nair
ADM-2017-0003 is Kabir Singh, section A
ADM-2099-0001 exists: False
10-A (4 students): 1.Aarav 2.Diya 3.Kabir 4.Meera
Different last names: 16; adding 'Sharma' again added a new one: False
Served first: Suresh Patel; next in line: Harpreet Singh; still waiting: 2
Undo removes 670; last mark now 85
List: found 1006 in 24.20 ms; Dictionary: found 1006 in 0.10 ms

Example: a real class list​

sectionA.Enrollments is the class list of 10-A: four students, in roll-number order, exactly as in SchoolDb. No searching was needed; the loader filled the list as it read the file.

TryGetValue found Kabir by his admission number, and the program followed kabir.Enrollments[0].Section to his section. Collections and navigation properties together let you move around the data the way a person thinks about it.

Example: unique values with HashSet​

Adding all 1,006 last names to a HashSet kept only 16 different ones. The generated students share a small set of family names, which is typical of test data.

Add returns false when the value is already there, so "Sharma" was not added twice. That return value is often the whole point: "have I already sent a reminder to this guardian?"

Example: queues and stacks​

The fee counter serves parents in the order they arrived: Dequeue returns Suresh Patel, who joined first, and Peek shows Harpreet Singh is next without removing him.

The stack of typed marks works the other way. The last entry, a mistyped 670, comes off first with Pop, leaving 85 on top. Undo features in editors and browsers work exactly like this.

Example: why the Dictionary was 240 times faster​

Three runs on a laptop:

Search for all 1,006 studentsRun 1Run 2Run 3
List, checking each item in turn24.20 ms24.21 ms31.07 ms
Dictionary, by key0.10 ms0.10 ms0.10 ms

Searching a list is like reading the register from the top for every student: on average, half the list for each search. A dictionary works like the filing cabinet: it uses the key to jump straight to the right drawer. Your timings will differ; the gap will not.

Example: read-only views​

A public List<T> property lets any caller add or remove items, which can break a class's rules, just like a public setter in Lesson 38. When callers should only read, expose the list as IReadOnlyList<T>:

Example
public IReadOnlyList<Enrollment> CurrentEnrollments => _enrollments;

Callers can loop, count, and read by position, but Add and Remove do not exist on that type. The class keeps the real list private and changes it only through its own methods.

Example: three errors to recognise​

CodeError
Adding a key twice with AddArgumentException: An item with the same key has already been added. Key: ADM-2017-0003
Reading a missing key with dict[key]KeyNotFoundException: The given key 'ADM-2099-0001' was not present in the dictionary.
Removing from a list inside its own foreachInvalidOperationException: Collection was modified; enumeration operation may not execute.

The third is the most common in real code. A foreach cannot continue once the list under it changes. Collect the items to remove first, or loop backwards with a for loop.

Common mistakes​

  1. Searching a list by key over and over. Slow, and slower as data grows. Fix: build a Dictionary once, then look up.
  2. dict[key] for a key that may be missing. It throws. Fix: TryGetValue.
  3. Changing a list inside its own foreach. It throws. Fix: change it after the loop.
  4. Collection properties that start as null. Every loop needs a null check. Fix: initialise them to an empty list.
  5. Parsing numbers with the computer's culture. 92.00 is misread on some machines. Fix: CultureInfo.InvariantCulture for files and APIs.

In real projects​

From 16 years of .NET projects

An end-of-term report on one school system took over four minutes. For each of 3,000 students, it searched a list of 90,000 marks to find theirs.

We grouped the marks into a dictionary keyed by student once, at the start. Each student's marks were then one lookup away, and the report ran in under three seconds.

No new hardware, no caching, no database change: just the right collection. When a loop inside a loop searches by an Id, I now reach for a dictionary first.

Best practices​

  • Use List<T> by default; arrays for fixed data.
  • Use a Dictionary whenever you look things up by a key more than once.
  • Use TryGetValue instead of the indexer when a key may be missing.
  • Initialise collection properties to empty collections, never null.
  • Parse file and API data with CultureInfo.InvariantCulture.

Recap​

  • List<T> grows as needed; arrays have a fixed size.
  • Dictionary<TKey, TValue> finds values by key in one step: 240 times faster than a list here.
  • HashSet<T> keeps unique values; Add returns false for duplicates.
  • Queue<T> is first in, first out; Stack<T> is last in, first out.
  • Student.Enrollments and Section.Enrollments link one object to many.
  • 1,006 real students and 12,012 marks are now available as CSV files.

Homework​

Library module task: Use a Dictionary to look up a book title by ISBN, a Queue for students waiting for The Jungle Book's only copy, and a HashSet of students who have borrowed a book.

Kabir, Meera, and Rohan wait in that order. Aarav and Advika have borrowed before; add Kabir and Aarav and report whether each is new.

A correct result looks like:

Output
9780141325293 is The Jungle Book
Copy returned. Next borrower: Kabir Singh; still waiting: 2
Borrowers: 3; Kabir new: True; Aarav new: False
Show the solution
library-assignment/047-library-collections.cs
// Library homework, Lesson 47: a waiting list, a lookup by ISBN, and a set of borrowers.
// Run with: dotnet run library-assignment/047-library-collections.cs

// Dictionary: ISBN -> title, for the three books in SchoolDb.
Dictionary<string, string> titles = new Dictionary<string, string>
{
["9788173711466"] = "Wings of Fire",
["9780141325293"] = "The Jungle Book",
["9780143039655"] = "Malgudi Days",
};
Console.WriteLine($"9780141325293 is {titles["9780141325293"]}");

// Queue: students waiting for The Jungle Book's only copy, in the order they asked.
Queue<string> waitingList = new Queue<string>();
waitingList.Enqueue("Kabir Singh");
waitingList.Enqueue("Meera Iyer");
waitingList.Enqueue("Rohan Das");
Console.WriteLine($"Copy returned. Next borrower: {waitingList.Dequeue()}; still waiting: {waitingList.Count}");

// HashSet: every student who has borrowed a book, counted once.
HashSet<string> borrowers = new HashSet<string> { "Aarav Sharma", "Advika Sharma" };
bool kabirIsNew = borrowers.Add("Kabir Singh");
bool aaravIsNew = borrowers.Add("Aarav Sharma");
Console.WriteLine($"Borrowers: {borrowers.Count}; Kabir new: {kabirIsNew}; Aarav new: {aaravIsNew}");

The ["9780141325293"] = "The Jungle Book" lines are a dictionary initializer: they add key and value pairs while the dictionary is created.

Quiz​

  1. Which collection finds a student by admission number in one step?

    1. List<Student>
    2. Dictionary<string, Student>
    3. Queue<Student>
    4. Stack<Student>
    Show answer

    Answer: B. Dictionary<string, Student> A dictionary uses the key to jump straight to the value.

  2. What does HashSet.Add return when the value is already in the set?

    1. true
    2. false
    3. An exception
    4. null
    Show answer

    Answer: B. false Duplicates are not added, and Add tells you so.

  3. Parents join a queue in the order Suresh, Harpreet, Venkat. Who does Dequeue return?

    1. Venkat
    2. Harpreet
    3. Suresh
    4. All three
    Show answer

    Answer: C. Suresh A queue is first in, first out.

  4. What happens if you remove items from a List inside a foreach over that list?

    1. They are removed safely
    2. InvalidOperationException: Collection was modified
    3. The loop restarts
    4. Nothing
    Show answer

    Answer: B. InvalidOperationException: Collection was modified A foreach cannot continue once its collection changes.

  5. Why initialise Enrollments to an empty list?

    1. It is faster
    2. Code can loop over it without checking for null
    3. EF requires it
    4. Lists cannot be null
    Show answer

    Answer: B. Code can loop over it without checking for null An empty list is safe to loop over; null is not.

Interview questions​

Questions are grouped by experience level. Click a question to see a model answer. All questions: Advanced C# interview questions.

Fresher (0-2 yrs)​

F What is the difference between an array and a List<T>? concept

An array has a fixed size set when it is created. A List<T> grows and shrinks with Add and Remove, using an array inside that it replaces with a bigger one when full. Use arrays for fixed data and lists for data that changes.

See the example · Permalink

F When would you use a Dictionary<TKey, TValue>? concept

When you look values up by a unique key, such as students by admission number. Lookup time stays about the same however many items it holds, unlike searching a list.

See the example · Permalink

F What is the difference between a Queue and a Stack? concept

A Queue<T> is first in, first out: Enqueue adds at the back, Dequeue takes from the front, like a fee counter line. A Stack<T> is last in, first out: Push and Pop work on the top, like undo.

See the example · Permalink

Mid (3-5 yrs)​

M Why is looking up by key in a Dictionary faster than searching a List? concept

A list search checks items one by one, so on average half the list per search. A dictionary hashes the key to jump almost directly to its bucket, taking about constant time. In the SMS, finding 1,006 students took about 24 ms with a list and 0.1 ms with a dictionary.

See the example · Permalink

M What is the difference between dict[key] and dict.TryGetValue(key, out value)? coding

The indexer throws KeyNotFoundException when the key is missing. TryGetValue returns false instead and gives the value through out when found. Use TryGetValue whenever the key may be absent.

See the example · Permalink

M What does 'Collection was modified; enumeration operation may not execute' mean? concept

The list was changed, for example by Remove, while a foreach was looping over it. Collect the items to change and apply the changes after the loop, or loop backwards with a for loop.

See the example · Permalink

Senior (6-9 yrs)​

S An end-of-term report takes minutes. It loops over students and, for each, searches a list of all marks. How do you fix it? scenario

The nested search does students times marks comparisons. Group the marks into a Dictionary keyed by student once, then look each student up in constant time. The work drops from millions of comparisons to one pass plus one lookup per student.

How to answer: Identify the nested loop as the cause, explain the complexity in plain terms, and show the dictionary fix with a measurement before and after.

See the example · Permalink

S How should a class expose a collection it owns? design

Keep the real list private and expose it as IReadOnlyList<T> or IReadOnlyCollection<T>, so callers can read but not add or remove. Changes go through the class's own methods, which keep its rules. Initialise collections to empty, never null.

How to answer: Connect it to encapsulation, show the property pattern, and mention that returning the mutable list lets callers bypass validation.

See the example · Permalink

Architect / Lead (10+ yrs)​

A How do you choose the right collection for a feature? design

Start from how the data is used: in order and by position (List), by key (Dictionary), uniqueness checks (HashSet), arrival order (Queue), last-first (Stack), and whether many threads share it (concurrent collections). Measure with realistic data volumes before optimizing further.

How to answer: Give a decision approach based on access patterns and scale, with SMS examples, and mention thread safety and memory as further factors.

See the example · Permalink

Resources​

  • Code for this lesson: git tag lesson-47-collections (code repository coming soon)
  • Files: src/SchoolApp.Console/DataFiles.cs, src/SchoolApp.Console/Program.cs, data/class10-students.csv, library-assignment/047-library-collections.cs
  • Video: coming soon

What's next​

List<Student> and Dictionary<string, Student> use angle brackets that this lesson only explained briefly. Next: generics, and how to write your own classes and methods that work with any type. Lesson 48: Generics

Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.