.net made easy[]

C# List<T> Explained: The Everyday Resizable Collection

BeginnerLesson 28 of 315·C# Foundations·19 min read·Updated

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.

List<T> = Array + Automatic Resizing
List<string> cart = ["Book", "Pen"]; // collection expression — clean, modern cart.Add("Notebook"); // grows automatically — now has 3 items

Why 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 it

Big Picture

ARRAY vs LIST<T>
Array
Fixed size at birth
No Add / Remove
Compact & fast
List<T>
Grows and shrinks as needed
Wraps an array internally
Flexible & convenient
List<T> is not a replacement for arrays — it's built on top of one, managed for you.

How It Works

USING LIST<T>, STEP BY STEP
1. CREATE A LIST
List<string> names = [];              // empty list
List<string> names2 = ["Ana", "Ben"];  // pre-filled list
▼
2. ADD, INSERT, AND REMOVE
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.
▼
3. READ BY INDEX, CHECK COUNT
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.
▼
4. ITERATE, SEARCH, SORT
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 shrank

Code → Meaning → Result:

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}"); // 2

Notice 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

HOW LIST<T> ACTUALLY GROWS
1. A LIST<T> WRAPS AN ARRAY
  • 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 single Add.
▼
2. WHAT HAPPENS WHEN CAPACITY RUNS OUT
  • When 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.
  • 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.
▼
3. WHY THIS MAKES "ADD AT THE END" CHEAP ON AVERAGE
  • Most 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.
  • Averaged over many additions, this makes Add very fast — described as "amortized O(1)."
▼
4. WHY INSERT/REMOVE IN THE MIDDLE ARE SLOWER
  • Because the backing array must stay contiguous with no gaps, Insert(0, ...) or Remove from the middle requires shifting every element after that position by one slot.
  • Adding/removing at the end avoids this shifting entirely — which is why Add is 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 error

Correct:

Console.WriteLine(scores.Count); // 2

Mistake 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> or Queue<T>.
Rule of thumb: When you're not sure which collection to use, start with 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

List<T> = a self-managing array
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


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>.