C# List<T> Explained: The Everyday Resizable Collection
List<T> is an array that grows for you — the same indexed, ordered collection, minus the "fixed size" problem.
Think about a shopping cart on an e-commerce site. You don't know in advance how many items a customer will add — it might be 1, it might be 40. An array can't help here: its size is locked in the moment you create it. You'd need to guess a size, and guessing wrong means either wasted space or a cart that silently can't hold any more items.
In this lesson, you'll learn about List<T> — the resizable, general-purpose collection you'll reach for constantly in real C# code — how it grows internally, and how it compares to the array you already know.
What Is It?
The Simple Explanation
A List<T> is like an array that can grow and shrink while your program runs. You add items with .Add(), remove them with .Remove(), and the list quietly handles resizing itself behind the scenes. You never have to guess a size up front.
The Technical Definition
List<T> is a generic, resizable, ordered collection class in the System.Collections.Generic namespace. The T is a placeholder for whatever type you're storing — List<int>, List<string>, List<Product>. It preserves insertion order, allows duplicate values, supports index-based access like an array, and automatically manages its own capacity as you add or remove elements.
- Generic —
Tlets oneListclass work with any type, safely and without casting. - Ordered — items stay in the order you added them, just like an array.
- Resizable — grows and shrinks with
Add,Insert, andRemove. - Indexed — you can still use
list[2], just like an array.
List<string> cart = ["Book", "Pen"]; // collection expression — clean, modern
cart.Add("Notebook"); // grows automatically — now has 3 itemsWhy Does It Exist?
The Problem
Recall from the arrays lesson: once created, an array's size is permanent. If you don't know how many elements you'll need — which is true for the vast majority of real-world data — you're stuck. You could try to work around it manually:
int[] cart = new int[10]; // guess: "10 items should be enough"
int count = 0;
void AddItem(int productId)
{
if (count == cart.Length)
{
// Array is full! Now what? Manually create a bigger array,
// copy every old element into it, then add the new one...
int[] bigger = new int[cart.Length * 2];
Array.Copy(cart, bigger, cart.Length);
cart = bigger;
}
cart[count] = productId;
count++;
}This works, but it's tedious, error-prone boilerplate that every program needing a growable collection would have to reinvent — and get subtly wrong.
The Solution
List<T> does exactly that "create a bigger array, copy everything over" dance for you, automatically, every time it runs out of room:
List<int> cart = [];
cart.Add(101); // just works, no matter how many times you call itBig Picture
Add / RemoveHow It Works
List<string> names = []; // empty list
List<string> names2 = ["Ana", "Ben"]; // pre-filled list
names.Add("Cara"); // adds to the end
names.Insert(0, "Aaron"); // inserts at a specific position
names.Remove("Ben"); // removes the first match by value
names.RemoveAt(0); // removes by index
Addis by far the most common and cheapest operation — it appends to the end.InsertandRemovein the middle are more expensive — every element after the change point has to shift.
string first = names[0];
Console.WriteLine(names.Count); // note: Count, not Length
- Arrays use
.Length;List<T>uses.Count. This trips up nearly every beginner at least once.
foreach (string name in names)
Console.WriteLine(name);
bool hasCara = names.Contains("Cara");
names.Sort();
Simple Example
List<int> scores = [];
scores.Add(95);
scores.Add(82);
scores.Add(71);
Console.WriteLine($"Count: {scores.Count}"); // 3
Console.WriteLine($"First: {scores[0]}"); // 95
scores.Remove(82);
Console.WriteLine($"After remove: {string.Join(", ", scores)}"); // 95, 71
Console.WriteLine($"Count: {scores.Count}"); // 2 — the list shrankCode → Meaning → Result:
- The list starts empty and grows with each
Add— no size decision was made up front. Remove(82)both deletes the value and shrinksCount— something an array simply cannot do.
Real-World Example
An e-commerce shopping cart is the canonical use case for List<T> — items are added and removed constantly as the customer shops, and the total is never known ahead of time.
public record CartItem(string Name, decimal Price, int Quantity);
public class ShoppingCart
{
private readonly List<CartItem> _items = [];
public IReadOnlyList<CartItem> Items => _items;
public void AddItem(CartItem item) => _items.Add(item);
public bool RemoveItem(string name)
{
var match = _items.FirstOrDefault(i => i.Name == name);
return match is not null && _items.Remove(match);
}
public decimal Total()
{
decimal total = 0;
foreach (var item in _items)
total += item.Price * item.Quantity;
return total;
}
}
var cart = new ShoppingCart();
cart.AddItem(new CartItem("Book", 19.99m, 2));
cart.AddItem(new CartItem("Pen", 2.50m, 3));
cart.AddItem(new CartItem("Notebook", 4.75m, 1));
Console.WriteLine($"Items in cart: {cart.Items.Count}"); // 3
Console.WriteLine($"Total: {cart.Total():C}"); // $52.23
cart.RemoveItem("Pen");
Console.WriteLine($"Items after removal: {cart.Items.Count}"); // 2Notice the cart exposes Items as IReadOnlyList<CartItem> rather than the raw List<CartItem> — outside code can look at the cart's contents, but can only change them through AddItem/RemoveItem. You'll see this exact interface, and several like it, formalized in the "Collection Interfaces" lesson later in this module.
Under the Hood
- Internally, every
List<T>keeps a private backing array — everything you learned about arrays applies here, just hidden behind a friendlier interface. - This backing array has a capacity — how many slots exist right now — which is usually larger than the list's current
Count, leaving room to grow without reallocating on every singleAdd.
- When
Addis called and the backing array is already full (Count == Capacity), the list allocates a brand-new, larger array — in .NET, capacity typically doubles. - Every existing element is then copied from the old array into the new one, and the old array is discarded for the garbage collector to reclaim.
- This is exactly the manual "create bigger array, copy everything" dance from the "Why Does It Exist?" section —
List<T>just does it for you, automatically, whenever it's needed.
- Most
Addcalls just drop the new value into the next free slot — no copying at all. Occasionally, when capacity is exhausted, one expensive "grow and copy" happens. - Averaged over many additions, this makes
Addvery fast — described as "amortized O(1)."
- Because the backing array must stay contiguous with no gaps,
Insert(0, ...)orRemovefrom the middle requires shifting every element after that position by one slot. - Adding/removing at the end avoids this shifting entirely — which is why
Addis the cheap, common operation and inserting at the front is comparatively expensive.
Common Confusion
1. .Length vs .Count
Arrays expose .Length. List<T> exposes .Count. There is no deep technical reason they differ — it's simply a naming inconsistency baked into the .NET class library decades ago, and every C# developer just learns to remember it.
2. "Count" (the property) vs "Count()" (a later LINQ method)
Later in this course you'll meet LINQ's Count() method, which works on almost any collection but has to actually iterate to count. On List<T>, always prefer the Count property shown in this lesson — it's a simple stored number, instant to read, with no iteration involved.
3. Capacity vs Count
Count is how many elements are actually in the list right now. Capacity is how many slots the backing array currently has reserved — often more than Count, to leave room to grow without reallocating on every single addition.
Common Mistakes
Mistake 1 — Using .Length on a List
Wrong — compiler error, List<T> has no Length:
List<int> scores = [95, 82];
Console.WriteLine(scores.Length); // compiler errorCorrect:
Console.WriteLine(scores.Count); // 2Mistake 2 — Modifying a list while iterating over it with foreach
Wrong — throws InvalidOperationException ("Collection was modified"):
foreach (int score in scores)
{
if (score < 80)
scores.Remove(score); // mutating the list mid-iteration
}Correct — iterate over a snapshot copy, or loop backwards by index:
for (int i = scores.Count - 1; i >= 0; i--)
{
if (scores[i] < 80)
scores.RemoveAt(i); // safe — going backwards avoids skipping elements
}Mistake 3 — Indexing past the end
Just like arrays, list[list.Count] throws ArgumentOutOfRangeException — the last valid index is always Count - 1, the same off-by-one rule you learned with arrays.
Mistake 4 — Reaching for List<T> when you never intend to change it
If a collection is genuinely fixed and never grows or shrinks, List<T> adds a small amount of unnecessary overhead compared to a plain array. It's rarely a real problem, but for fixed, performance-sensitive data, an array can be the better fit.
When Should I Use It?
Use List<T> when
- You don't know the final size ahead of time, or it changes while the program runs.
- You need to add or remove items regularly (a cart, a to-do list, a queue of pending tasks).
- You want array-like indexing and ordering, plus built-in convenience methods (
Sort,Contains,Find). - You're not sure which collection to reach for —
List<T>is the sensible general-purpose default.
Reach for something else when
- The size is genuinely fixed and known → a plain array is lighter weight.
- You need fast lookups by a meaningful key, not position →
Dictionary<TKey, TValue>. - Duplicates must never be allowed →
HashSet<T>. - You specifically need last-in-first-out or first-in-first-out order →
Stack<T>orQueue<T>.
List<T>. It's the most common, most flexible, general-purpose collection in .NET — you can always switch to something more specialized once you know your access pattern.
Mental Model
Add() = "grow the array if you have to, then put this at the end"
Count = how many items are really there right now
Remember:
· Under the hood, it's still an array — with automatic resizing bolted on.
·
Count, not Length — a naming quirk worth memorizing.· Adding to the end is cheap; inserting/removing in the middle shifts everything after it.
Key Takeaway
- List<T> is a resizable, ordered, indexed collection — the array's flexible cousin.
- Under the hood, it wraps a backing array and automatically grows it (typically doubling capacity) when it runs out of room.
- Use
.Count, not.Length, to check how many elements are stored. Addat the end is cheap;Insert/Removein the middle shift elements and are more costly.- Use
List<T>as your default collection whenever the size can change — reach for arrays, dictionaries, sets, stacks, or queues only when their specific strengths fit your problem better.
Check Your Understanding
You've seen how List<T> gives you a resizable, array-like collection. Let's check your understanding.
1. What is the fundamental difference between an array and a List<T>?
Show answer
Correct: B
Why B is correct: This is the defining distinction. An array's length is locked in when it's created. List<T> is built specifically to add and remove elements freely, resizing its internal backing array as needed.
Why A is incorrect: List<T> is generic — it can hold any type, exactly like an array can.
Why C is incorrect: Both arrays and lists support foreach just fine.
Why D is incorrect: They behave very differently once you need to add or remove elements — that's precisely the situation where the choice matters.
Reinforcement: Resizability is the single biggest reason to choose List<T> over a plain array.
2. What happens internally when you call Add on a List<T> whose backing array is already full?
Show answer
Correct: C
Why C is correct: When capacity runs out, List<T> allocates a new, larger array (typically double the size), copies every existing element over, and only then adds the new value — all automatically, without you having to write that logic yourself.
Why A is incorrect: Unlike a fixed array, a full List<T> is never "stuck" — it simply grows.
Why B is incorrect: The new item is always added; nothing is silently dropped.
Why D is incorrect: List<T> doesn't evict old items to make room — it grows its capacity instead. (A fixed-capacity, oldest-evicted structure is a different concept entirely.)
Reinforcement: The "resize and copy" work you'd have to do manually with an array is exactly what List<T> automates for you.
3. Why is list.RemoveAt(0) generally more expensive than list.Add(value) (appending to the end)?
Show answer
Correct: B
Why B is correct: The backing array must stay contiguous with no gaps. Removing the first element means every other element has to shift left by one slot. Adding to the end, by contrast, usually just drops the value into the next free slot — no shifting needed.
Why A is incorrect: RemoveAt is a real, commonly used List<T> method.
Why C is incorrect: Most Add calls don't trigger a resize at all — only the occasional call that exceeds current capacity does.
Why D is incorrect: Their cost differs specifically because of where in the list the operation happens — front operations are pricier than end operations.
Reinforcement: Operations at the end of a List<T> are cheap; operations at the front or middle require shifting elements to preserve contiguous storage.
4. An e-commerce team is choosing between an array and a List<T> to represent a shopping cart's items, where the customer adds and removes products while browsing. Which is the better fit, and why?
Show answer
Correct: B
Why B is correct: A shopping cart's item count is inherently unpredictable and changes constantly — exactly the scenario List<T> was built for. An array would force a hard, arbitrary size limit chosen in advance.
Why A is incorrect: Arrays aren't universally faster — they're only a good fit when the size is genuinely fixed, which a shopping cart is not.
Why C is incorrect: A cart needs to track multiple distinct items with their own details (name, price, quantity), not a single number.
Why D is incorrect: Real shopping carts routinely hold many items — assuming otherwise would make the design fragile from day one.
Reinforcement: When the number of elements changes at runtime, List<T> is almost always the right default choice.
You now have the most-used collection in everyday C# code in your toolkit. Next up: fast lookups by key with Dictionary<TKey, TValue>.