Every one of these mistakes feels obvious in hindsight — and every experienced developer made every single one of them on the way here.
You've now been through the whole Foundations tier: variables and types, control flow, methods, OOP, collections, error handling, files, and modern C# syntax. Along the way, a handful of mistakes trip up nearly every beginner — not because the concepts are poorly explained, but because some of C#'s behavior is genuinely surprising the first time you encounter it, and some habits from other languages (or from just guessing) lead you somewhere subtly wrong.
This lesson is a guided tour through the mistakes that show up again and again — pulled from across everything you've learned so far. None of them are exotic edge cases; they're the ordinary potholes that catch nearly everyone once. Reading through them now, deliberately, means you'll recognize them instantly the first time your own code hits one — instead of spending an hour confused about why something that "should" work doesn't.
Most of the mistakes below share a common root: C# behaves in a very precise, consistent way — but that behavior isn't always what your intuition expects, especially if your intuition was built on a different language, or on assumptions that happen to work by coincidence most of the time. The fix for nearly every mistake here isn't "try harder" — it's understanding the one specific rule that makes the surprising behavior make sense, after which it stops being surprising at all.
== vs .EqualsoverrideException too broadlystaticAssigning a struct (value type) copies the whole value. Assigning a class (reference type) copies only the reference — both variables end up pointing at the same object.
// Point is a struct (value type)
Point p1 = new Point(1, 1);
Point p2 = p1; // COPY — independent
p2.X = 99;
Console.WriteLine(p1.X); // 1 — unaffected
// Order is a class (reference type)
Order o1 = new Order { Total = 100 };
Order o2 = o1; // SAME OBJECT — not a copy
o2.Total = 999;
Console.WriteLine(o1.Total); // 999 — o1 changed too!
The fix: know which of your types are structs and which are classes, and remember that assigning a reference-type variable never copies the object — it copies the address that points to it. If you need an independent copy of a reference type, you must create one explicitly.
== When You Meant .Equals (or Vice Versa)For reference types, == by default checks whether two variables point to the exact same object in memory — not whether their contents are equivalent.
var c1 = new Customer { Name = "Alice" };
var c2 = new Customer { Name = "Alice" };
Console.WriteLine(c1 == c2); // False — different objects in memory
Console.WriteLine(c1.Equals(c2)); // Also False, unless Customer overrides Equals
// records get value-based equality automatically:
public record CustomerRecord(string Name);
var r1 = new CustomerRecord("Alice");
var r2 = new CustomerRecord("Alice");
Console.WriteLine(r1 == r2); // True — records compare by value
The fix: for plain classes, == and the default .Equals both mean "same object" unless you deliberately override them. If you want "same content", either override Equals/GetHashCode yourself, or — much more often the right move in modern C# — use a record, which gives you value-based equality for free.
override — and Silently Hiding a Method Insteadpublic class Animal
{
public virtual void Speak() => Console.WriteLine("...");
}
public class Dog : Animal
{
public void Speak() => Console.WriteLine("Woof!"); // missing "override" — this HIDES, not overrides
}
Animal a = new Dog();
a.Speak(); // Prints "..." — NOT "Woof!" — because Speak() isn't virtual dispatch here
Without override, the compiler treats Dog.Speak() as an entirely new, unrelated method that happens to share a name — it usually warns you about this ("hides inherited member"), but a beginner can easily miss the warning. Calling Speak() through an Animal-typed reference then calls the base version, not the derived one — the opposite of what polymorphism is supposed to do.
The fix: if you intend to replace a base class's behavior, always add override (and make sure the base method is virtual or abstract in the first place). Pay attention to compiler warnings about hidden members — they exist precisely to catch this mistake.
Exception Too Broadly (or Swallowing It Silently)try
{
var expense = ParseExpense(input);
SaveExpense(expense);
}
catch (Exception)
{
// catches EVERYTHING — including bugs you'd want to know about —
// and throws away all information about what actually failed
}
Catching the broadest possible exception type and doing nothing with it hides real bugs (a NullReferenceException from a genuine coding mistake looks exactly the same as an expected FormatException from bad user input) and makes debugging painful, since the whole point of the stack trace and exception type — covered earlier in this Part — gets thrown away.
The fix: catch the specific exception type you actually expect and know how to handle (FormatException, FileNotFoundException, etc.), and at minimum log anything you catch more broadly. Never leave a catch block empty.
var scores = new List<int> { 90, 85, 77 }; // valid indices: 0, 1, 2
for (int i = 0; i <= scores.Count; i++) // should be i < scores.Count
{
Console.WriteLine(scores[i]); // throws when i == 3
}
A List<T> with 3 items has valid indices 0 through 2 — using <= instead of < against Count runs one iteration too many and throws an ArgumentOutOfRangeException (or, with a raw array, IndexOutOfRangeException).
The fix: for zero-based indexing, the upper bound in a for loop should almost always be <, not <=, when comparing against .Count or .Length. When possible, prefer foreach over an indexed for loop — it eliminates this entire category of mistake, since there's no index to get wrong.
var expenses = new List<Expense> { e1, e2, e3 };
foreach (var expense in expenses)
{
if (expense.Amount > 1000)
expenses.Remove(expense); // throws InvalidOperationException:
} // "Collection was modified"
A foreach loop uses an enumerator internally, and that enumerator detects if the underlying collection changes shape mid-iteration — .NET deliberately throws rather than silently producing undefined, inconsistent results.
// Option 1: iterate over a snapshot copy
foreach (var expense in expenses.ToList())
{
if (expense.Amount > 1000)
expenses.Remove(expense);
}
// Option 2: use RemoveAll for the common "remove matching items" case
expenses.RemoveAll(e => e.Amount > 1000);
// Option 3: a plain indexed loop counting DOWN, so removed items
// don't shift the indices you haven't visited yet
for (int i = expenses.Count - 1; i >= 0; i--)
{
if (expenses[i].Amount > 1000)
expenses.RemoveAt(i);
}
The fix: never add or remove items from a collection you're actively foreach-ing over. Iterate a copy, use a built-in method like RemoveAll, or loop backward by index instead.
public class Customer
{
public string? MiddleName { get; set; } // explicitly nullable
}
void PrintMiddleName(Customer customer)
{
// compiler warning: MiddleName might be null here
Console.WriteLine(customer.MiddleName.ToUpper()); // crashes if null
}
With nullable reference types enabled (the modern C# default), the compiler actively warns you when you're about to dereference something that might be null. Beginners often dismiss these as noise and press on — right up until that exact line throws a NullReferenceException in the one case where the value actually was null.
The fix: treat nullable warnings as the compiler doing your null-checking for you, for free. Check before dereferencing (if (customer.MiddleName is not null)), or use the null-conditional operator (customer.MiddleName?.ToUpper()).
static and Hidden Shared Statepublic class ExpenseTracker
{
public static List<Expense> AllExpenses = new(); // global mutable state
public static void AddExpense(Expense e) => AllExpenses.Add(e);
}
// Now ANY code anywhere in the program can silently mutate AllExpenses,
// with no clear owner and no way to control who's changing it or when.
A static field is shared by the entire program — there's exactly one copy, reachable from anywhere, with no natural boundary around who's allowed to change it. This can seem convenient early on ("I don't have to pass this around!"), but it quietly makes code much harder to reason about, since any method anywhere could be responsible for an unexpected change.
The fix: prefer instance fields and passing objects explicitly between methods and classes. Reserve static for things that are genuinely global and stateless by nature (utility methods like Math.Max, or true constants) — not for mutable data that represents your application's evolving state.
The first time you drive somewhere unfamiliar, you miss the same turns everyone misses — the exit that's oddly placed, the light that changes faster than you expect. Nobody tells you about every quirk in advance; you learn the map by driving it, once, and after that you never miss that turn again.
Every mistake in this lesson is one of those turns. You're not a worse driver for having missed one before someone pointed it out — you're just now someone who's driven this particular road and knows exactly where it bends.
== on classes compares identity, not content — unless overridden or a record.override silently hides instead of replacing.<, not <=, against .Count.foreach-ing.== vs .Equals, and missing override are three of the most common type-system traps.catch blocks hide real bugs instead of surfacing them — catch specific exceptions and always log what you catch.You've toured eight of the most common mistakes across the whole Foundations tier. Let's see if you can now spot them in code.
1. What will this code print?
public class Account { public decimal Balance; }
var a = new Account { Balance = 100 };
var b = a;
b.Balance = 500;
Console.WriteLine(a.Balance);
Correct: B
Why B is correct: Account is a class — a reference type. b = a copies the reference, not the object, so a and b point to the exact same Account in memory. Changing b.Balance changes the one shared object, so reading it through a shows the update too.
Why A is incorrect: That would be true if Account were a struct (value type), where assignment copies the whole value independently.
Why C is incorrect: Nothing resets Balance to zero anywhere in this code.
Why D is incorrect: This code is valid and compiles without error.
Reinforcement: Assigning a reference-type variable never copies the object — both variables end up pointing at the same instance.
2. A junior developer writes a catch (Exception) { } block with nothing inside it around a section of code that parses user input and saves it to a file. What's the biggest problem with this?
Correct: B
Why B is correct: An empty catch (Exception) block catches everything — expected and unexpected alike — and throws away all information about what failed and why. The program appears to continue normally while silently failing to do what it was supposed to do.
Why A is incorrect: This code compiles fine; the problem is a design/behavior issue, not a syntax error.
Why C is incorrect: Catching the base Exception type catches every exception type that derives from it — essentially everything — not just ArgumentException.
Why D is incorrect: A plain catch block has no automatic retry behavior; that would need to be explicitly coded.
Reinforcement: Never leave a catch block empty — at minimum, log what was caught so the failure is visible somewhere.
3. Which loop correctly avoids an off-by-one error when iterating over the valid indices of a List<T> named items?
Correct: C
Why C is correct: With zero-based indexing, valid indices run from 0 to Count - 1. Using i < items.Count stops exactly one before Count, covering every valid index without overshooting.
Why A is incorrect: i <= items.Count allows i to reach Count itself, which is one past the last valid index — this throws an out-of-range exception.
Why B is incorrect: Starting at 1 skips index 0 entirely, silently missing the first item.
Why D is incorrect: This makes the off-by-one error even worse, running two iterations past the valid range.
Reinforcement: For zero-based indexing, the correct upper bound comparison against .Count is always <, never <=.
4. What happens when you call list.Remove(item) from inside a foreach (var item in list) loop over that same list?
Correct: B
Why B is correct: A foreach loop's enumerator detects structural changes to the underlying collection mid-iteration and deliberately throws rather than continuing with undefined behavior.
Why A is incorrect: This is exactly the intuitive-but-wrong assumption that trips up beginners — it does not work as expected and throws instead.
Why C is incorrect: The exception is thrown, not silently swallowed — the program stops with a clear error rather than quietly producing wrong results.
Why D is incorrect: There's no infinite loop here; the exception is thrown well before that could happen.
Reinforcement: Never add or remove items from a collection while a foreach is actively iterating over it — iterate a copy, use RemoveAll, or loop backward by index instead.
5. A derived class writes public void Speak() without the override keyword, even though the base class declares public virtual void Speak(). When called through a base-typed reference, what happens?
Correct: C
Why C is correct: Without override, the derived method is a completely separate, unrelated method that happens to share a name — it hides the base member rather than participating in virtual dispatch. A base-typed reference calls the base class's version, not the derived one, defeating the point of polymorphism.
Why A is incorrect: This compiles fine — usually with a warning, not an error, which is exactly why it's easy to miss.
Why B is incorrect: That would be true if override had been used correctly; without it, the reference's declared type — not the object's actual type — determines which version runs.
Why D is incorrect: Only one version runs per call; there's no mechanism that would run both.
Reinforcement: If you intend to replace inherited behavior, always pair a virtual base member with an explicit override in the derived class.
You've now seen the mistakes almost every C# developer makes at least once — recognizing them here means you'll catch them fast when they show up in your own code.
dotnetmadeeasy.com — Learn C# and .NET, the right way.