C# & .NET (C# 14, .NET 10 LTS) Module 5 – Arrays and collections
Lists and collection expressions
How a C# List<T> grows by doubling, what Add, Insert and Remove cost, how collection expressions build lists, arrays and spans, and how to share a list safely.
What you will learn
- Add, insert, remove and search List<T> items and predict what each operation costs
- Measure how Capacity grows, and size a list up front when the count is known
- Build arrays, lists and spans with collection expressions and the spread element
- Choose how a class hands out its list so that callers cannot change it
Before you start
What you’ll learn · Before you start
What you will learn
- Add, insert, remove and search List<T> items and predict what each operation costs
- Measure how Capacity grows, and size a list up front when the count is known
- Build arrays, lists and spans with collection expressions and the spread element
- Choose how a class hands out its list so that callers cannot change it
Before you start
On this page
A waiting list that grows to 1,000 bookings one Add at a time makes only 9 arrays and copies 1,020 items along the
way, about one copy per booking. Inserting each booking at the front instead was about 300 times slower than adding at
the end and reversing once.
You will see how a List<T> grows, what each of its operations costs, how collection expressions build lists, arrays
and spans in one syntax, and how to hand a list to other code without handing over control of it.
A list keeps its items in an array that doubles when full
A List<T> is an array with spare room. Count is the number of items in use and Capacity the length of the array
inside. While there is room, Add writes the next slot. When Count equals Capacity, the reference documentation
says the list reallocates its internal array and copies the existing elements before adding. This program adds
bookings one by one and prints each change of Capacity, then counts what 1,000 Adds cost:
// A waiting list that grows one booking at a time. Capacity is the length of the array inside the list.
var waiting = new List<int>();
Console.WriteLine($"new List<int>() Count {waiting.Count,2}, Capacity {waiting.Capacity,2}");
int capacity = waiting.Capacity;
for (int pnr = 1; pnr <= 17; pnr++)
{
int before = waiting.Count;
waiting.Add(pnr);
if (waiting.Capacity != capacity)
{
Console.WriteLine($"Add #{pnr,-2} Count {waiting.Count,2}, Capacity {waiting.Capacity,2}: a new array of {waiting.Capacity}, {before} items copied into it");
capacity = waiting.Capacity;
}
}
// 1,000 Adds without a capacity, then with one: arrays made, items copied, bytes allocated.
long start = GC.GetAllocatedBytesForCurrentThread();
var grown = new List<int>();
int arrays = 0;
int copied = 0;
for (int i = 0; i < 1_000; i++)
{
if (grown.Count == grown.Capacity)
{
arrays++;
copied += grown.Count;
}
grown.Add(i);
}
long grownBytes = GC.GetAllocatedBytesForCurrentThread() - start;
start = GC.GetAllocatedBytesForCurrentThread();
var sized = new List<int>(1_000);
for (int i = 0; i < 1_000; i++)
{
sized.Add(i);
}
long sizedBytes = GC.GetAllocatedBytesForCurrentThread() - start;
Console.WriteLine($"1,000 Adds to new List<int>() {arrays} arrays, {copied} items copied, {grownBytes} bytes, Capacity {grown.Capacity}");
Console.WriteLine($"1,000 Adds to new List<int>(1000) 1 array, 0 items copied, {sizedBytes} bytes, Capacity {sized.Capacity}");
grown.TrimExcess();
Console.WriteLine($"TrimExcess() Capacity {grown.Capacity}"); Output
new List<int>() Count 0, Capacity 0 Add #1 Count 1, Capacity 4: a new array of 4, 0 items copied into it Add #5 Count 5, Capacity 8: a new array of 8, 4 items copied into it Add #9 Count 9, Capacity 16: a new array of 16, 8 items copied into it Add #17 Count 17, Capacity 32: a new array of 32, 16 items copied into it 1,000 Adds to new List<int>() 9 arrays, 1020 items copied, 8424 bytes, Capacity 1024 1,000 Adds to new List<int>(1000) 1 array, 0 items copied, 4056 bytes, Capacity 1000 TrimExcess() Capacity 1024
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run growth.cs
Step through one full array, using the run’s numbers:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- An empty list. A new List<int>() has no array yet: Count 0 and Capacity 0. Capacity is the length of the array inside the list; Count is how many items are in use.
- The first Add. Add #1 finds no room, so the list makes an array of 4 and stores the item. Adds 2, 3 and 4 write into the spare room: nothing is allocated or copied.
- Full. Add #5 finds Count equal to Capacity. The list cannot make its array longer, any more than an array can grow.
- Twice the room. It allocates a new array of 8, twice the old capacity. The run printed this step as Capacity 8 at Add #5.
- Copy, then store. The 4 items are copied into the new array, item 5 goes after them, and the old array is left for the garbage collector. Count is 5.
- The pattern repeats. Adds 6 to 8 use the spare room again. Add #9 copies 8 items into an array of 16, and Add #17 copies 16 into one of 32, as the run printed.
- A thousand Adds. For 1,000 Adds the list made 9 arrays, from 4 to 1,024, and copied 1,020 items in all: about one copy per Add. A list made with new List<int>(1000) made 1 array and copied nothing.
The capacity went 4, 8, 16, 32: each new array is twice the last, so copies get rarer as the list grows. That is why
Add is amortised cost: The average cost of an operation over a long run of them. List<T>.Add sometimes copies every item into a larger array, but because the array doubles, the copies average out to about one per Add, so Add is amortised O(1). O(1): an occasional Add copies everything, but the average stays
about one copy per item. When you know the count, pass it: new List<int>(1000) made one array and allocated 4,056
bytes instead of 8,424. TrimExcess() left Capacity at 1,024, because it trims only when more than 10 % is unused.
Checkpoint
Check: How a list grows
Adding at the end is cheap; inserting at the front moves every item
An array keeps its items side by side, so a list can add at the end without touching the others, while inserting at
index 0 must move every item one place up. The documentation gives each operation its cost, where n is Count:
| Operation | Cost | Why |
|---|---|---|
list[i], read or write |
O(1) | goes straight to slot i |
Add(x) |
O(1) amortised | writes the next slot; O(n) when it must grow |
Insert(i, x) |
O(n) | moves the items from i on one place up |
RemoveAt(i) |
O(n − i) | moves the items after i one place down |
Contains(x), IndexOf(x), Remove(x) |
O(n) | compares with each item in turn |
This program puts 20,000 bookings in newest-first order three ways and times each:
using System.Diagnostics;
using System.Globalization;
// 20,000 bookings put into a list three ways; each way runs 5 times and the fastest run counts.
const int N = 20_000;
CultureInfo inv = CultureInfo.InvariantCulture;
int[] expected = [.. Enumerable.Range(0, N).Reverse()]; // newest booking first
Report("Insert(0, x) for each", () =>
{
var list = new List<int>();
for (int i = 0; i < N; i++)
{
list.Insert(0, i); // moves every item already in the list one place up
}
return list;
});
Report("Add(x), then Reverse()", () =>
{
var list = new List<int>();
for (int i = 0; i < N; i++)
{
list.Add(i); // writes at the end: nothing moves
}
list.Reverse(); // one pass over the list
return list;
});
Report("RemoveAt(0) until empty", () =>
{
var list = new List<int>(expected);
var taken = new List<int>(N);
while (list.Count > 0)
{
taken.Add(list[0]);
list.RemoveAt(0); // moves every item after index 0 one place down
}
return taken;
});
void Report(string name, Func<List<int>> build)
{
if (!build().SequenceEqual(expected))
{
throw new InvalidOperationException($"{name} gave a different order");
}
double best = double.MaxValue;
for (int run = 0; run < 5; run++)
{
var clock = Stopwatch.StartNew();
build();
best = Math.Min(best, clock.Elapsed.TotalMilliseconds);
}
Console.WriteLine($"{name,-26}{best.ToString("F2", inv),9} ms");
} Output
Insert(0, x) for each 17.85 ms Add(x), then Reverse() 0.06 ms RemoveAt(0) until empty 17.88 ms
This output changes from run to run: the times depend on the computer and on what else it is doing
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run costs.cs
On this run Insert(0, x) took 17.85 ms and Add then Reverse() 0.06 ms, about 300 times faster for the same
order, and taking items from the front with RemoveAt(0) was as slow as inserting there. Twice the bookings would
make the front-insert version about four times slower, because insert k moves k items.
Using a List<T> as a queue
list.RemoveAt(0) to take the oldest item moves every other item down, so a busy queue in a list does work that
grows with the square of its length. A Queue<T> takes from the front in O(1); a later lesson of this module
covers it.
Checkpoint
Check: What each list operation costs
Collection expressions build lists, arrays and spans with one syntax
Square brackets build a collection: [6, 7, 8] is a collection expression (C# 12), and a
spread element: An element written .. collection inside a collection expression, such as [.. morning, 12]. It puts every element of the collection in its place, in order; the collection can be anything a foreach loop can read., .. morning, puts all the elements of another collection in its
place. The type you assign it to decides what is built:
using System.Collections.Immutable;
// Departure hours of a day's trains, built with collection expressions.
int[] morning = [6, 7, 8];
int[] evening = [17, 18, 19];
List<int> departures = [.. morning, 12, .. evening]; // spread both arrays around one more hour
Console.WriteLine($"List<int> [{string.Join(", ", departures)}], Count {departures.Count}, Capacity {departures.Capacity}");
// The same syntax for other targets; the type on the left decides what is built.
int[] copy = [.. departures];
Span<int> firstThree = [6, 7, 8];
ImmutableArray<int> fixedHours = [.. departures];
HashSet<string> codes = ["NDLS", "MAS", "NDLS"];
Console.WriteLine($"int[] Length {copy.Length}, a different object: {!ReferenceEquals(copy, morning)}");
Console.WriteLine($"Span<int> Length {firstThree.Length}");
Console.WriteLine($"ImmutableArray<int> Length {fixedHours.Length}");
Console.WriteLine($"HashSet<string> Count {codes.Count}: the second NDLS was not added");
// Interfaces: the compiler picks a type that can be changed only when the interface allows changes.
IList<int> canChange = [1, 2, 3];
IReadOnlyList<int> cannotChange = [1, 2, 3];
Console.WriteLine($"IList<int> a {canChange.GetType().Name}, IsReadOnly {canChange.IsReadOnly}");
Console.WriteLine($"IReadOnlyList<int> IsReadOnly {((ICollection<int>)cannotChange).IsReadOnly}");
int[] none = [];
Console.WriteLine($"int[] none = [] Length {none.Length}, the shared Array.Empty<int>(): {ReferenceEquals(none, Array.Empty<int>())}"); Output
List<int> [6, 7, 8, 12, 17, 18, 19], Count 7, Capacity 7 int[] Length 7, a different object: True Span<int> Length 3 ImmutableArray<int> Length 7 HashSet<string> Count 2: the second NDLS was not added IList<int> a List`1, IsReadOnly False IReadOnlyList<int> IsReadOnly True int[] none = [] Length 0, the shared Array.Empty<int>(): True
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run expressions.cs
Step through the first line, the departures list:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The type on the left decides. A collection expression has no type of its own. Here the variable is a List<int>, so the compiler builds a List<int>; the same brackets would build an int[] for an int[] variable.
- Count first. morning and evening are arrays, so their lengths are known before anything is copied: 3 + 1 + 3 = 7 elements.
- Room for 7. The list is made with room for exactly 7, which is why the run printed Capacity 7. Seven Adds to an empty list would have made two arrays, of 4 and then 8, and ended at Capacity 8.
- Spread, item, spread. The elements go in the order they are written: 6, 7 and 8 from morning, then 12, then 17, 18 and 19 from evening. Nothing is copied twice.
- Done. departures holds 7 hours in a list of capacity 7. Changing morning later does not change it: the expression copied the elements when it ran.
The list came out with Capacity 7, not the 8 that seven Adds would leave: the compiler counted the elements first.
The same brackets built an int[], a Span<int>, an ImmutableArray<int> and a HashSet<string>, which kept one
NDLS. Collection expressions work for interfaces too: an IList<int> got a List<int> that can change, and an
IReadOnlyList<int> a type whose IsReadOnly is true. [] gave the shared empty array, which costs nothing.
Key idea
A list grows by doubling, so Add at the end is cheap and anything at the front moves every item; collection
expressions build lists, arrays and spans alike, sized to their elements.
Checkpoint
Check: Collection expressions and spreads
Removing many items without quadratic time, measured Pro: opens with a Pro, Premium or Ultimate pass
Removing every cancelled booking from a list looks like a three-line loop, and the obvious loop is both wrong and slow. The run below removes 10,000 of 20,000 bookings four ways and counts what each one leaves behind.
In this part:
- Four ways to remove cancelled bookings, timed and counted
- Inserting at the front, and what to use instead
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Sharing a list without handing over control Pro: opens with a Pro, Premium or Ultimate pass
A class that hands out its own List<T> hands out the power to change its data. The run below exposes a coach’s
passenger list four ways, shows what a caller can do with each, and counts what each costs per call.
In this part:
- Four ways to expose a list, and what a caller can do with each
- A span over a list goes stale when the list grows
2 runnable examples (C#) · about 3 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Sorting a list: unstable Sort and stable OrderBy, measured Pro: opens with a Pro, Premium or Ultimate pass
Two sorts of the same bookings can disagree about the order of equal keys, and the difference can reach a report or
a test. The run below counts how often List<T>.Sort reorders equal keys, and what the stable alternative allocates.
In this part:
- Equal keys come out in any order with List.Sort
- Two keys, or a stable sort, and what each allocates
1 runnable example (C#) · about 3 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise: a playlist that never throws
playlist.cs checks a Playlist class that you finish. It keeps its songs in a private list and moves or removes
them by index, returning false for a move or a removal that is not possible. Only the first check passes until the
three methods are written:
// Practice: finish the Playlist class so that every check below passes, then run this file again.
// MoveUp and MoveDown swap a song with its neighbour; Remove takes a song out by its index.
// A move or a remove that is not possible returns false and leaves the playlist as it was.
var checks = new List<(string Name, Func<Playlist, bool> Act, bool Returns, string After)>
{
("Add three songs", p => true, true, "Raga, Tala, Dhun"),
("MoveUp(1)", p => p.MoveUp(1), true, "Tala, Raga, Dhun"),
("MoveUp(0) at the top", p => p.MoveUp(0), false, "Raga, Tala, Dhun"),
("MoveDown(2) at the end", p => p.MoveDown(2), false, "Raga, Tala, Dhun"),
("MoveDown(0)", p => p.MoveDown(0), true, "Tala, Raga, Dhun"),
("Remove(1)", p => p.Remove(1), true, "Raga, Dhun"),
("Remove(3) past the end", p => p.Remove(3), false, "Raga, Tala, Dhun"),
("MoveUp(-1)", p => p.MoveUp(-1), false, "Raga, Tala, Dhun"),
};
int passed = 0;
foreach (var (name, act, returns, after) in checks)
{
string verdict;
try
{
var playlist = new Playlist();
playlist.Add("Raga");
playlist.Add("Tala");
playlist.Add("Dhun");
bool got = act(playlist);
string songs = string.Join(", ", playlist.Songs);
verdict = got == returns && songs == after ? "ok" : $"FAIL, returned {got} with {songs}";
}
catch (Exception e)
{
verdict = $"FAIL, {e.GetType().Name}";
}
if (verdict == "ok")
{
passed++;
}
Console.WriteLine($"{name}: {verdict}");
}
Console.WriteLine($"{passed} of {checks.Count} checks pass");
return passed == checks.Count ? 0 : 1;
sealed class Playlist
{
private readonly List<string> _songs = [];
// Callers can read the songs but not change them.
public IReadOnlyList<string> Songs => _songs.AsReadOnly();
public void Add(string song) => _songs.Add(song);
public bool MoveUp(int index) => throw new NotImplementedException("Check index, then swap _songs[index] and _songs[index - 1].");
public bool MoveDown(int index) => throw new NotImplementedException("Check index, then swap with the song after it.");
public bool Remove(int index) => throw new NotImplementedException("Check index, then RemoveAt.");
} Output (exit status 1)
Add three songs: ok MoveUp(1): FAIL, NotImplementedException MoveUp(0) at the top: FAIL, NotImplementedException MoveDown(2) at the end: FAIL, NotImplementedException MoveDown(0): FAIL, NotImplementedException Remove(1): FAIL, NotImplementedException Remove(3) past the end: FAIL, NotImplementedException MoveUp(-1): FAIL, NotImplementedException 1 of 8 checks pass
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run playlist.cs
Finish the playlist
Copy playlist.cs and write MoveUp, MoveDown and Remove. Check the index against _songs.Count first, and swap
two songs with (_songs[i], _songs[i - 1]) = (_songs[i - 1], _songs[i]). Run the file with dotnet run playlist.cs
until it prints 8 of 8 checks pass.
The questions below go back over capacity, the cost table and collection expressions, the weakest idea first:
Practice that adapts to you
Practise this lesson
7 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What is the difference between Count and Capacity of a List<T>?
Count is how many items the list holds; Capacity is the length of the array inside it, the number of items it can
hold before it must allocate a larger array. Add writes into spare room while Count is below Capacity; when they
are equal, the list allocates a new array, about twice as long, and copies every item into it. In a run of 1,000 Adds
the capacity went 4, 8, 16 and on to 1,024, nine arrays in all. Passing the count you expect, new List<int>(1000),
makes one array and no copies, and TrimExcess() gives unused room back when it is more than 10 % of the array.
-
Fresher to mid
A service keeps the latest 50,000 events newest-first in a List<T>: Insert(0, e) for each new event, then RemoveAt(list.Count - 1) to drop the oldest. Under load, most of its CPU time goes to these two calls. Why, and what would you use instead?
-
Mid to senior
A class exposes its internal List<Order> as IReadOnlyList<Order> so that callers cannot change it. A plugin casts the property back to List<Order> and adds orders, which corrupts the class's totals. How do you stop this, and what does each option cost per call?
-
Mid to senior
A report sorts bookings by coach with List<T>.Sort. After an unrelated change to how bookings are loaded, bookings inside each coach appear in a different order and a snapshot test fails. Nothing in the sort changed. Explain what happened and how you would make the order fixed.
Key takeaways
- A
List<T>keeps its items in an array that doubles when full, soAddis amortised O(1) and a known count belongs in the constructor. - Reading by index and adding at the end are cheap, while
Insert,RemoveAt,ContainsandRemovemove or compare items one by one. - Collection expressions, with spread elements, build lists, arrays, spans and sets, and the target type decides which.
Terms in this lesson
- amortised cost
- The average cost of an operation over a long run of them. List<T>.Add sometimes copies every item into a larger array, but because the array doubles, the copies average out to about one per Add, so Add is amortised O(1).
- spread element
- An element written .. collection inside a collection expression, such as [.. morning, 12]. It puts every element of the collection in its place, in order; the collection can be anything a foreach loop can read.
References
- List<T> class (Microsoft Learn)
- List<T>.Add method (Microsoft Learn)
- List<T>.Insert method (Microsoft Learn)
- List<T>.RemoveAt method (Microsoft Learn)
- List<T>.Sort method (Microsoft Learn)
- Enumerable.OrderBy method (Microsoft Learn)
- Collection expressions (C# reference) (Microsoft Learn)
- Collections and data structures (algorithmic complexity of collections) (Microsoft Learn)
- ReadOnlyCollection<T> class (Microsoft Learn)
- CollectionsMarshal class (Microsoft Learn)
- GC.GetAllocatedBytesForCurrentThread method (Microsoft Learn)
Report a problem with this lesson
Kept only in this browser. Your Learn progress