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.
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.
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.
T lets one List class work with any type, safely and without casting.Add, Insert, and Remove.list[2], just like an array.List<string> cart = ["Book", "Pen"]; // collection expression — clean, modern
cart.Add("Notebook"); // grows automatically — now has 3 itemsRecall 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.
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 itAdd / RemoveList<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
Add is by far the most common and cheapest operation — it appends to the end.Insert and Remove in 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
.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();
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:
Add — no size decision was made up front.Remove(82) both deletes the value and shrinks Count — something an array simply cannot do.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.
List<T> keeps a private backing array — everything you learned about arrays applies here, just hidden behind a friendlier interface.Count, leaving room to grow without reallocating on every single Add.Add is called and the backing array is already full (Count == Capacity), the list allocates a brand-new, larger array — in .NET, capacity typically doubles.List<T> just does it for you, automatically, whenever it's needed.Add calls 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.Add very fast — described as "amortized O(1)."Insert(0, ...) or Remove from the middle requires shifting every element after that position by one slot.Add is the cheap, common operation and inserting at the front is comparatively expensive..Length vs .CountArrays 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.
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.
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.
.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); // 2foreach 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
} 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.
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.
Sort, Contains, Find).List<T> is the sensible general-purpose default.Dictionary<TKey, TValue>.HashSet<T>.Stack<T> or Queue<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.
Count, not Length — a naming quirk worth memorizing..Count, not .Length, to check how many elements are stored.Add at the end is cheap; Insert/Remove in the middle shift elements and are more costly.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.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>?
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?
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)?
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?
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>.
dotnetmadeeasy.com — Learn C# and .NET, the right way.