Collections: List, Dictionary, HashSet, Queue, Stack – C# | Lesson 47
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>andStack<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
| Collection | Holds | Best for | School office version |
|---|---|---|---|
List<T> | Items in order, by position | Most everyday lists | The class register: entries in order |
Dictionary<TKey, TValue> | Values found by a unique key | Fast lookup by Id or code | The 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 out | Processing in arrival order | Parents waiting at the fee counter |
Stack<T> | Last in, first out | Undo, going back | A 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> | |
|---|---|---|
| Size | Fixed when created | Grows and shrinks as needed |
| Add an item | Not possible | list.Add(student) |
| Count | array.Length | list.Count |
| Remove an item | Not possible | list.Remove(student) |
| Speed of reading by position | Fastest | Almost 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.
| Operation | Code | When the key is missing |
|---|---|---|
| Add | dict.Add(key, value) | Throws if the key already exists |
| Read | dict[key] | Throws KeyNotFoundException |
| Safe read | dict.TryGetValue(key, out value) | Returns false |
| Check | dict.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:
| File | Rows | Columns |
|---|---|---|
data/class10-students.csv | 1,006 | AdmissionNumber, FirstName, LastName, DateOfBirth, Gender, Section, RollNumber |
data/class10-marks.csv | 12,012 | AdmissionNumber, 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:
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
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
// 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");
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 students | Run 1 | Run 2 | Run 3 |
|---|---|---|---|
List, checking each item in turn | 24.20 ms | 24.21 ms | 31.07 ms |
Dictionary, by key | 0.10 ms | 0.10 ms | 0.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>:
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
| Code | Error |
|---|---|
Adding a key twice with Add | ArgumentException: 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 foreach | InvalidOperationException: 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
- Searching a list by key over and over. Slow, and slower as data grows. Fix: build
a
Dictionaryonce, then look up. dict[key]for a key that may be missing. It throws. Fix:TryGetValue.- Changing a list inside its own
foreach. It throws. Fix: change it after the loop. - Collection properties that start as
null. Every loop needs a null check. Fix: initialise them to an empty list. - Parsing numbers with the computer's culture.
92.00is misread on some machines. Fix:CultureInfo.InvariantCulturefor files and APIs.
In real 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
Dictionarywhenever you look things up by a key more than once. - Use
TryGetValueinstead 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;Addreturnsfalsefor duplicates.Queue<T>is first in, first out;Stack<T>is last in, first out.Student.EnrollmentsandSection.Enrollmentslink 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:
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 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
Which collection finds a student by admission number in one step?
Show answer
Answer: B. Dictionary<string, Student> A dictionary uses the key to jump straight to the value.
What does HashSet.Add return when the value is already in the set?
Show answer
Answer: B. false Duplicates are not added, and Add tells you so.
Parents join a queue in the order Suresh, Harpreet, Venkat. Who does Dequeue return?
Show answer
Answer: C. Suresh A queue is first in, first out.
What happens if you remove items from a List inside a foreach over that list?
Show answer
Answer: B. InvalidOperationException: Collection was modified A foreach cannot continue once its collection changes.
Why initialise Enrollments to an empty list?
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.