What are collections in C#? Compare an array, ArrayList and List<T>; what is the difference between IEnumerable, ICollection and IList; what is HashSet, and which collection would you use when?
11 reports in the question index (7 linked here), from screenings and first technical rounds: EPAM, Excellon Software, Wipro, Solsynch and others. It arrives as "collections in C#", then narrows to array vs ArrayList, IEnumerable, or HashSet.
Say this first · about 30 seconds
A collection is a group of items your code works through. The choice comes down to four questions: is the size fixed or does it grow, does order matter, must every item be unique, and do you look items up by position, by value or by key.
An array is the fastest and smallest, but its length is fixed when it is created. ArrayList is the old list from before generics, when the item type was not written down: it stores every item as an object, so a number gets wrapped in an object and you cast it back on the way out. List<T> is the replacement: you write the item type once in the angle brackets, the compiler checks every use, and nothing is wrapped.
The interfaces are about what a caller is allowed to do. The enumerable interface only lets you loop over the items; the collection interface adds a count, add and remove; the list interface adds an indexer, items by position, and an insert method. I return the narrowest one the caller needs, usually a read-only list, so they cannot change my list behind my back.
A HashSet is for uniqueness and membership. Add returns false for a duplicate. Contains is fast on average: it turns the value into a hash, a number, and checks one small bucket (a group of items that share a hash) instead of scanning. The catch is that it uses the type's equality rules, so a plain class with no override compares by reference, and changing a field used in the hash after insertion makes the item unfindable.
My rule is an array for a fixed buffer, a list for an ordered growable sequence, a set for unique items or fast membership, and a dictionary when each item needs a key. I also check the cost and whether the collection is shared across threads, because the standard list and dictionary are not thread-safe, meaning two threads changing them at once can corrupt them.
Make it yours · Navistar Financial end-of-term excess mileage (invented example)
Situation: at end of term, 38,000 Navistar Financial trucks came back with a , the vehicle identification number that uniquely names the truck, and a mileage reading. A nightly job had to find the open contract for each so it could charge the excess miles. The open contracts sat in a of 6,000, and the job scanned the list for every truck. On a test of that size the scan took 0.83 seconds; in production the feed is larger, and scanning once per truck makes the work grow much faster than the data.
What I did: I built a of the open once, before the truck loop, and asked the set instead. A set turns the string into a hash, a number, and checks one bucket (a small group that shares a hash), so a miss costs one lookup, not 6,000 string comparisons.
Result: the same test then took 6 milliseconds. A test with 10,000 fake checked that the set and the old list found the same contracts, so the speed came without a behaviour change.
What I'd say: a is for order; a is for membership and uniqueness. If the question is "is this open?", a list is the wrong tool, and doing it once per truck makes the cost grow with the square of the data (100 trucks against 100 contracts is 10,000 checks).
The code, simplified
The matcher, in C#. A nightly job calls FindOpen with the 6,000 open contracts and the 38,000 returned . Tap Next to walk them through the set.
Navistar.sln · where this file sits
Mileage/
VinMatcher.cs · line 1 · 1 of 14A class is a reference type: a variable would hold an address to an object. says this class is only a home for methods: you cannot create one with .
Fix later in your repoExample written for this card: invented, not from a real codebase.
Say it out loud, then check
Show the 30-second answer
A collection is a group of items your code works through. The choice comes down to four questions: is the size fixed or does it grow, does order matter, must every item be unique, and do you look items up by position, by value or by key.
An array is the fastest and smallest, but its length is fixed when it is created. ArrayList is the old list from before generics, when the item type was not written down: it stores every item as an object, so a number gets wrapped in an object and you cast it back on the way out. List<T> is the replacement: you write the item type once in the angle brackets, the compiler checks every use, and nothing is wrapped.
The interfaces are about what a caller is allowed to do. The enumerable interface only lets you loop over the items; the collection interface adds a count, add and remove; the list interface adds an indexer, items by position, and an insert method. I return the narrowest one the caller needs, usually a read-only list, so they cannot change my list behind my back.
A HashSet is for uniqueness and membership. Add returns false for a duplicate. Contains is fast on average: it turns the value into a hash, a number, and checks one small bucket (a group of items that share a hash) instead of scanning. The catch is that it uses the type's equality rules, so a plain class with no override compares by reference, and changing a field used in the hash after insertion makes the item unfindable.
My rule is an array for a fixed buffer, a list for an ordered growable sequence, a set for unique items or fast membership, and a dictionary when each item needs a key. I also check the cost and whether the collection is shared across threads, because the standard list and dictionary are not thread-safe, meaning two threads changing them at once can corrupt them.
Check your answer: tick what you said
You said 0 of 9
Then add your example: Navistar Financial end-of-term excess mileage (invented example)
Situation: at end of term, 38,000 Navistar Financial trucks came back with a , the vehicle identification number that uniquely names the truck, and a mileage reading. A nightly job had to find the open contract for each so it could charge the excess miles. The open contracts sat in a of 6,000, and the job scanned the list for every truck. On a test of that size the scan took 0.83 seconds; in production the feed is larger, and scanning once per truck makes the work grow much faster than the data.
What I did: I built a of the open once, before the truck loop, and asked the set instead. A set turns the string into a hash, a number, and checks one bucket (a small group that shares a hash), so a miss costs one lookup, not 6,000 string comparisons.
Result: the same test then took 6 milliseconds. A test with 10,000 fake checked that the set and the old list found the same contracts, so the speed came without a behaviour change.
What I'd say: a is for order; a is for membership and uniqueness. If the question is "is this open?", a list is the wrong tool, and doing it once per truck makes the cost grow with the square of the data (100 trucks against 100 contracts is 10,000 checks).
See it
Stock reportprints lines
Contract cacheone shared list
Dealer portalreads the cache
Read-only viewthe fix
Open()
the same List
1/6The report asks the shared cache (data kept in memory) for its contracts: the cache's own List.The report prints 6,000 contracts, then calls Clear to free memory after the run.Clear empties the one list the portal also reads. The portal now shows zero contracts.Fix: the cache returns AsReadOnly, a read-only wrapper around the same list.The report's Clear no longer compiles: a read-only list has no Clear method.The portal reads its contracts again, and no copy of 6,000 rows was made.
In plain words
Collection: any group of items your code works through: an array, a list, a set, a dictionary. Each is a different trade: fixed or growable, ordered or not, unique or not, found by position, by value or by key.
Array: a block of created with a fixed length, numbered from zero (the index). says how many, and items[0] reads the first. It is the smallest and fastest for reading by position, but you cannot add a : builds a new, bigger array and copies the old items.
List<T>: a growable, ordered list. The <T> is a generic type parameter, a placeholder for the item type: you write the item type once, so the compiler knows what comes out and no cast is needed. appends, is how many, items[0] reads by index, and duplicates are allowed. Its capacity is the size of the hidden array; when it is full, makes a bigger one (4, then 8, then 16...) and copies. That copy is rare, so the average is still O(1): people call it amortised O(1).
ArrayList: the old, non-generic list from before generics. It stores every item as an object, so an int is boxed (copied into a new heap object) on the way in, and you must cast on the way out. It is slower and loses type safety; use .
The interfaces: is the weakest promise: you can loop over it with , nothing else. adds ,,, and . adds the indexer (items[i]) and . A implements all of them; is a with an index and , but no .
HashSet<T>: a set of unique items. returns false when the item is already there, and is constant time on average because it turns the value into a hash (a number) and looks in one bucket, instead of scanning. It promises no order. It uses the type's (is this the same item?) and (which bucket?), so a class without them compares by reference, and an object whose hash field changes after insertion cannot be found.
Dictionary<TKey,TValue>: a set of unique keys, each with a value. dict[key] finds the value by hash, and asks without throwing. with an existing key throws; dict[key] = value overwrites. is like a dictionary with keys only.
Order of processing: a is first in, first out; a is last in, first out. Use them when the order of the work is the point, not just storage. A is also ordered, but removing from the front is O(n) because every item after it shifts.
Cost: O(1) means the work stays about the same as the collection grows (a hash lookup); O(n) means it grows with the items (a scan). A scan inside a loop over n items is O(n²), which is how in a nightly loop turns a fraction of a second into much longer as the feed grows. Measure with a or BenchmarkDotNet before claiming a speed-up.
Threads: and are not thread-safe: one thread writing while another reads can throw or corrupt the collection. For shared writes use (a thread-safe dictionary) or a (a C# keyword that lets one thread enter a block at a time); or give each task its own copy.
The senior answer · about 2 minutes
How I choose a collection
I ask four questions in order. Is the number of items fixed, is order part of the meaning, must items be unique, and how will the caller find an item: by position, by value, or by a key? The answers pick the type before any code is written.
Fixed buffer: an array. Ordered and growing: a . Unique items or a yes/no membership test: a . A value per key: a . Work processed in a special order: a queue or a stack. Threads writing at once: a concurrent collection such as (a thread-safe dictionary), or a .
Then I check the cost of the operation the code actually performs. Index access on an array and a list is O(1); a list search is O(n); a set or dictionary lookup is O(1) on average. A scan inside a loop over the same data turns into O(n²), which is the collection bug I see most often in reviews.
Array, ArrayList and List<T>
An array is one block of created with a fixed length. The index is the position, starting at zero, and is fixed forever. Reading or writing a is O(1), and arrays are the cheapest collection in memory, which is why they sit inside the other collections.
comes from the first .NET, before generics. It stores every item as an object, so an int is boxed: .NET allocates a new object on the heap and copies the number into it. On the way out the compiler needs a cast back, and a wrong cast is a run-time exception. Its speed also pays for the boxing and the extra objects the garbage collector (the part of .NET that frees heap objects nobody uses) must clean.
is the generic replacement. The compiler knows the item type, so there is no boxing and no cast, and the same code works for any type. It wraps a hidden array: is amortised O(1) because the capacity doubles when the array is full, and the indexer is O(1). and are O(n) because they scan. It is not thread-safe.
On a BMW FS contract feed I keep the statement lines in a because order and duplicates matter: two contracts can share a , and both must print. I only reach for an array when the count is fixed by the product, like the twelve monthly of a yearly schedule.
The interfaces, and what I return
The interfaces are a ladder of promises. only promises that can walk the items. adds ,,, and . adds the indexer, , and . implements all of them, and offers the read parts without the changes.
I return the narrowest promise the caller needs. A repository that hands back its internal lets any caller call or on the cache's own data. Returning makes that call a compile error, and wraps the list so a cast back to fails; a cast to compiles, but then throws at run time. It is a wrapper, not a copy, so changes the cache makes later are still visible, which is what a cache should do.
On the BMW FS dealer portal, the nightly stock report asked the shared contract cache (data kept in memory for reuse) for its open contracts and then called to "free memory" after printing. It emptied the one list the portal was reading, so the portal showed zero open contracts until the next refresh. The fix was two lines: the cache returns through , and the report has no to call. A compile error is a better guard than a comment.
The same ladder matters for laziness. LINQ (built-in query methods like and ) is lazy: a query such as open.Where(c => c.IsSettled) does nothing until something loops it, and it runs again on the next loop. So I never loop it twice by accident; if I need the items twice, I call once, which copies them into a list, and keep that. Returning is right for a stream of results, not for a cache that must not re-query.
HashSet, equality, and the slow VIN job
answers one question well: is this value already here? It stores no duplicates, returns false when the value is present, and is O(1) on average. It does that by calling to pick a bucket and to confirm the match, so the type's equality is the whole contract.
A plain class with no or uses reference equality: two objects with the same are different keys. In a quick test, a set of plain objects kept two with the same , while a set of a (a class where two objects with the same fields count as equal) with the same field kept one. If the identity is the , key the set by the string, or give the type value equality.
The other trap is mutation. If an object is in a set and a field used by changes, the set looks in the new bucket and the object is lost; a test showed turn false after the changed. Keys in a set or dictionary must not change after they go in.
The Navistar job is the performance side. Replacing with a turns 38,000 trucks times 6,000 contracts into 38,000 hash lookups: on my test, 0.83 seconds became 6 milliseconds. The test checked the matches were identical, not just faster.
What I left behind
A test with duplicate and missing that runs both matchers and compares the answers, so a future "optimisation" cannot quietly change which contracts are charged.
A review rule: no inside a loop over another collection without a comment saying why a set or dictionary was not used; no mutation of a collection inside a ; and every performance change carries a measurement in the .
One statement run, every collection
The BMW FS month-end statement run, in the order the data meets each collection. Each type is there because of one property: fixed length, order, uniqueness, or lookup by key.
decimal[12]fixed at creation
month slotread by index
1/6The yearly schedule starts as a 12-slot array: one per month, created once.The 6,000 statement lines go into a List: order matters, and the count is not known up front.The paid instalment numbers go into a HashSet: membership is the question, and duplicates are noise.Each contract is filed by number in a Dictionary, so the run finds its statement with one key.Callers get the narrowest view: IEnumerable to loop, IReadOnlyList to read by index.Array for fixed, List for order, set for membership, Dictionary for a key, interfaces at the edge.
The story
Before
BMW FS's dealer portal kept its open contracts in one shared , a store of data kept in memory so the next caller does not rebuild it: a private loaded at start-up, read by the portal on every page. A nightly stock report also needed those contracts. It asked the cache for them, printed them, and then called to free memory after the run. Within a minute the portal was showing zero open contracts to every dealer, until the next refresh loaded the cache again.
Why it had to change
is a reference type: the variable holds an address to the object, not the list. So cache.Open() handed the report the cache's own list, and open in the report was a second name for it (a ). does exactly what it says on that one list: the portal's next read found nothing. Nothing threw, because every call was legal; the code did what the developer asked, just not what they meant.
How it works now
The fix changes the promise, not the data. is an with an index and , but no or . returns a wrapper around the same list: the portal keeps reading the live cache, with no copy, while the report's stops compiling. A compile error is cheaper than an incident.
What we gained
The portal kept its contracts and the report kept working. The bigger gain is the rule: any class that owns a collection decides what callers may do with it, and returns the narrowest interface that still does the job. A caller that needs to loop gets ; one that needs to read by index gets ; only the owner keeps the .
In the lending API
The fix is the return type: the cache hands out a read-only view, not its own list.
Only the key lines are shown; Deep walks the whole file.
Lending.sln · where this file sits
src/Application/Contracts/
⋯ 1 line in Deep
After · ContractCache.cs · line 6 · 1 of 2 promises an index and only. There is no or to call.
The code, file by file
The cache and the report that cleared it, before and after. The contract model comes first; tap Next to follow the run.
Diagram · step 1The report asks the shared cache (data kept in memory) for its contracts: the cache's own List.
Stock reportprints lines
Contract cacheone shared list
Dealer portalreads the cache
Read-only viewthe fix
Open()
the same List
Contract.cs · line 1 · 1 of 3class: a reference type, so a variable holds a to the object, not the object itself.
What they ask next
The follow-ups interviews really ask. Answer out loud, then tap to check.
ReportedWhat is the difference between IEnumerable, ICollection and IList?
They are a ladder of promises. gives you and nothing else. adds ,,, and . adds the indexer, , and . A implements all three, so the same object can be handed out through any of them; the interface you choose tells the caller what they are allowed to do.
IEnumerable<Contract> loop = list;
ICollection<Contract> bag = list;
IList<Contract> byIndex = list;
IReadOnlyList<Contract> read = list;
// read.Clear(); does not compile
InterviewerWhy does changing a list inside foreach throw?
keeps a version number, and its enumerator (the small object a uses to walk the list) records the version it started with. ,, and even setting an item by index increase it, so the next (the call that moves to the next item) sees a different collection and throws with "Collection was modified; enumeration operation may not execute." Setting an item by index throws too. The fix is to collect the items first and remove after the loop, or walk backwards with a for. after the loop is safe because it does not use the public enumerator.
foreach (Contract c in contracts)
{
if (c.IsSettled)
contracts.Remove(c); // throws
}
// safe: one pass, after the loop
contracts.RemoveAll(c => c.IsSettled);
ReportedWhat is HashSet, and when do you use it instead of a List?
A is a set of unique items with fast membership. returns false when the item is already there, and is O(1) on average because it hashes the value and checks one bucket. Use it when the question is "have I seen this?" or "is this allowed?", not when order matters; it promises no order. It uses the type's and , so a class without them compares by reference.
var vins = newHashSet<string>();
vins.Add("VIN-1"); // true
vins.Add("VIN-1"); // falsebool open = vins.Contains("VIN-9");
Answer, then open the next level. That is the order an interviewer digs in.
Reporteda candidate was asked Intervieweran interviewer asks it
ReportedWhat is the difference between IEnumerable, ICollection and IList?
They are a ladder of promises. gives you and nothing else. adds ,,, and . adds the indexer, , and . A implements all three, so the same object can be handed out through any of them; the interface you choose tells the caller what they are allowed to do.
IEnumerable<Contract> loop = list;
ICollection<Contract> bag = list;
IList<Contract> byIndex = list;
IReadOnlyList<Contract> read = list;
// read.Clear(); does not compile
Because the return type is the caller's permission slip. says "you may add, remove and clear my list"; says "read it". It does not copy the data, and cannot be cast back to ; a cast to compiles, but then throws at run time, so the guard still holds.
Does IEnumerable mean the query runs once?
No. An can be a lazy query: the work happens when you loop, and a second runs it again. If you need the items twice, put them in a list once with and keep that list. A already holds its items; it does not re-run anything.
InterviewerWhy does changing a list inside foreach throw?
keeps a version number, and its enumerator (the small object a uses to walk the list) records the version it started with. ,, and even setting an item by index increase it, so the next (the call that moves to the next item) sees a different collection and throws with "Collection was modified; enumeration operation may not execute." Setting an item by index throws too. The fix is to collect the items first and remove after the loop, or walk backwards with a for. after the loop is safe because it does not use the public enumerator.
foreach (Contract c in contracts)
{
if (c.IsSettled)
contracts.Remove(c); // throws
}
// safe: one pass, after the loop
contracts.RemoveAll(c => c.IsSettled);
Does every collection throw when it changes during foreach?
No, and that is the trap. The check is per collection and per operation. A throws on add, remove and index-set. On .NET 10, a threw on but not when I removed the current item, and a did not throw on . Never rely on the exception to protect you: do not change any collection while a is walking it.
ReportedWhat is HashSet, and when do you use it instead of a List?
A is a set of unique items with fast membership. returns false when the item is already there, and is O(1) on average because it hashes the value and checks one bucket. Use it when the question is "have I seen this?" or "is this allowed?", not when order matters; it promises no order. It uses the type's and , so a class without them compares by reference.
var vins = newHashSet<string>();
vins.Add("VIN-1"); // true
vins.Add("VIN-1"); // falsebool open = vins.Contains("VIN-9");
Why did my HashSet keep two objects with the same value?
Because the objects are different references and the class has no or override. uses the default (the object that decides whether two items are equal): reference equality for a class, value equality for a or a type that overrides both. Key the set by the natural value ( of ), or make the type a /override equality. In a quick test, the class set held two and the record set held one.
Can I change an item after adding it to a HashSet?
Not a field used by . The set filed the object under the old hash; after the change the new hash points at another bucket, and returns false even though the object is still inside; a quick test did exactly that. Treat keys in a set or dictionary as immutable (never change them after adding), or remove, change and re-add.
Is HashSet ordered?
No order is promised. In a small test it may look like insertion order, because of how the buckets happen to fall, but that is an implementation detail. If order matters, keep a for the order and a for the membership check.
Array vs ArrayList vs List<T>: which is faster, and why?
An array is the fastest and smallest for a fixed count: one block, O(1) index, no per-item object. adds a little overhead for the count and the growth strategy, but it is close, and it is type-safe with no boxing. is the slowest of the three for value types: every int is boxed into an object on , and every read needs a cast. A quick test added 100,000 ints: allocated about 4.5 megabytes, about 1 megabyte (its growing array only).
How does List<T> grow?
It keeps an array and a count. A new list has capacity 0; the first makes capacity 4; when full, it doubles (8, 16, 32...) and copies the old items. Doubling makes amortised O(1), and is the array size, not the item count. A quick test showed capacity 4 after the first and 8 after the fifth.
How do I get an array out of a List?
copies the items into a new array. does not expose its internal array: it is private and may be longer than ; gives a right-sized copy.
When would you use a Dictionary instead of a HashSet?
When each key carries a value. A gives O(1) average lookup and remembers one value per key; a is the same structure with keys only. with an existing key throws, dict[key] = value overwrites, and is the safe read when a miss is normal, because it reports the miss without throwing. Both need stable / and neither is thread-safe for writes.
var contract = newContract("VIN-1");
var map = newDictionary<string, Contract>();
map["C-1042"] = contract;
// out var c: filled when the key existsif (map.TryGetValue("C-9999", out var c))
{
Console.WriteLine(c.Vin);
}
Are List and Dictionary thread-safe?
No. A or can throw or corrupt its internal state when one thread writes while another reads. For shared writes use , a , or a collection designed for the job; for read-only sharing after construction, a list that is never changed is safe to read from many threads. The collection type does not make your own invariants thread-safe either: two calls then need a lock around the pair.
What does AsReadOnly actually stop?
It returns a wrapper: the caller cannot add, remove or clear through it, and cannot cast it back to . A cast to compiles, but or then throws at run time. It is not a copy, so changes made through the original list are visible. If the caller must have a frozen snapshot, use and return that as ; if they should see updates, is the cheaper choice.
Production bugs, and how to tell them
Each one: what you see, why, the fix in code, and the line to say.
The same contract was charged twice
Navistar Financial dealer support ticket:
"We were charged excess mileage twice for contract C-1042. The two rows are the same contract."
(no exception, no error log)
Cause
The nightly charge run merged two feeds and used a to drop repeated rows. has no or , so the set compared by reference: two objects for C-1042 were two keys. Both passed , so both went to the ledger.
Fix
Key the set by the contract number, , so the value decides. A test sends two rows for C-1042 and asserts one ledger row. The contract number is the identity here: a can have more than one contract over time, so a is not safe for this dedupe.
Diagram · step 1Two feeds send the same charge twice: two objects for C-1042.
Charge runadds to a set
Charge row 1C-1042
Charge row 2C-1042
Charge.cs · line 1 · 1 of 3One excess-mileage row. has no or , so a set compares references.
Say it
A set of a plain class compares by reference, so two charge rows for C-1042 were two keys and we charged twice. I keyed the set by the contract number, and a test proves one row. The number is the identity; a can have more than one contract.
Settled contracts stayed on the month-end report
Navistar Financial month-end reconciliation:
"The report shows 412 settled contracts and the balance is $1.9m too high."
(no exception, no error log)
Cause
The cleanup used a for loop that moved forward and called lines.RemoveAt(i). Removing at i shifts the next line into that , then i++ steps past it. When two settled contracts were next to each other, the second was never checked and stayed on the report. No exception, because no enumerator was involved.
Fix
Remove settled lines with lines.RemoveAll(l => l.IsSettled) after the read pass, or walk the list backwards. A test puts two settled contracts side by side and asserts none are left.
Diagram · step 1The list is A (settled), B (settled), C (open). The loop starts at i = 0.
Remove loopi moves forward
Report linesA, B, C
ReportLine.cs · line 1 · 1 of 2One row on the month-end report.
Say it
Removing by index while moving forward skips the item that shifts into the gap, so adjacent settled contracts survived. I use RemoveAll, and a test with two settled contracts in a row proves none are left.
Production incident: A third of the dealer statements never went out
P2 · serious, has a workaroundNavistar Financialcame with a deployMonth-end dealer statements batch (a .NET worker on Azure)
Composed, not a real event at Navistar Financial. The mechanism is real C#: a List<T> throws InvalidOperationException when it changes during a foreach. Dates, counts, names, the ticket, the query and the pull request are composed.
The story in 5 lines
On Wed 7 Oct, Navistar Financial's month-end dealer statements run stopped at 02:14. It had written 6,240 of 9,400 contracts, so 3,160 statements were missing when the dealer portal opened.
Release 5.2, deployed the evening before, had added a memory-saving step: remove a settled contract from the list as soon as its statement was written.
The removal happened inside the foreach that was walking the list. List<T>'s enumerator checks a version number on every MoveNext, so the next turn threw InvalidOperationException and the died.
Nothing alerted: the sent one heartbeat at the start, and nothing compared statements written with contracts due. found the same exception once and a gap of 3,160 statements.
The fix moved the removal after the loop with RemoveAll, and added a test with a settled contract that asserts every statement is written.
Who's who (4 people)
You
.NET developer on the vendor team. Finds the cause, writes the fix and the test.
Tech lead
Vendor senior. Reviews the hotfix, runs the postmortem.
L2 pod
Vendor support. Checks the run, reruns the batch, confirms the statements.
Navistar dealer services lead
Owns dealer statements and dealer communications. Decides what dealers are told.
Tap a step. The map lights the place where it happens.
Batch jobloop
foreachversion
Contracts9,400
Storerows
6,240 written
3,160 missing
1/4 Detect
Detect: the job failed at 02:14. 6,240 of 9,400 statements were written; 3,160 are missing.
Wed 7 Oct, 02:14 CDT (12:44 IST)The on-call L2 pod, then you
What: the month-end dealer statements job stopped after 6,240 of 9,400 contracts. The 3,160 contracts that came later in the list had no statement when the portal opened at 07:00.
Why it hid: the sent one heartbeat at the start. No check compared statements written with contracts due, so a failed run with partial writes looked like a normal night.
How we saw it: showed one exception, then the stopped. compared the contracts due with the statements written.
App Insights: the exception
exceptions
| where timestamp > datetime(2026-10-07)
| where type contains "InvalidOperation"
| project timestamp, message
| take 3
In theory
A run should compare statements written with contracts due and alert on the gap, not only on the 's heartbeat.
In practice
The alert watched the 's heartbeat only, so a run that died with a partial write left its last heartbeat looking normal.
Try it yourself · 10 minutes, on your Mac
dotnet new console -n ListLab. Add var nums = new List<int> { 1, 2, 3 };.
In a , call nums.Remove(2) when the value is 2. Run it: InvalidOperationException: Collection was modified.
Move the removal after the loop: nums.RemoveAll(n => n == 2);. Run again: no exception, and the list holds 1 and 3.
Change the list to a and add an item during : in my run it throws on too. Removing the current item did not throw in my run, which is why the rule is to change nothing.
You should see
A list that changes during foreach throws at the next MoveNext. Do the change after the loop, and test with an item that triggers it.
The senior move
"Never change a list while a foreach is walking it. My fix writes every statement first, then removes settled contracts with one RemoveAll after the loop, and a test with a settled contract proves every row is written."
SourcesSourced: List<T>'s enumerator throws InvalidOperationException when the list changes during enumeration (List<T> docs; a run with a net8.0 target reproduced the message and the MoveNext frame); RemoveAll removes every match in one pass without the public enumerator (List<T> docs). Composed: Navistar's systems, dates, counts, names, the , the ticket and the query.
Traps
Don't say is just an older .SayNot the same: stores object, boxes value types and needs casts. is type-safe and avoids the boxes. Use it for every new list.
Don't say has a .SayOnly and promise . On an , LINQ may walk the whole sequence, or re-run the query, so put it in a list once with if you need the number twice.
Don't say keeps insertion order.SayNo order is promised; a test that looks ordered is luck. Keep a for order and a for fast membership when you need both.
Don't say is fine for reads while another thread writes.SayIt is not: and are not thread-safe. Use a lock, a concurrent collection, or a copy per task.
Traps
What should you say instead? Tap to check.
Don't say is just an older .
Say instead
Not the same: stores object, boxes value types and needs casts. is type-safe and avoids the boxes. Use it for every new list.
Don't say has a .
Say instead
Only and promise . On an , LINQ may walk the whole sequence, or re-run the query, so put it in a list once with if you need the number twice.
Don't say keeps insertion order.
Say instead
No order is promised; a test that looks ordered is luck. Keep a for order and a for fast membership when you need both.
Don't say is fine for reads while another thread writes.
Say instead
It is not: and are not thread-safe. Use a lock, a concurrent collection, or a copy per task.
Live coding
Easy first, then harder. Set a timer and type it before you look.
Task 1
Reportedeasy · 10 minutes
Predict the three lines this program prints, and say for each whether the List or the HashSet produced it. Then say which collection kept the duplicates and which kept the input order.
One file. A new console app adds using System; (for ) and imports for you, which is where and live. asks the compiler to infer the type from the right-hand side.
Lending.sln · where this file sits
Stuck? Hints
Does List.Add check whether the value is already there?
What does HashSet.Add return when the value is already in the set?
Which collection lets you read by position?
Show a solution
Lending.sln · where this file sits
Output (real run) · line 1 · 1 of 3The list holds A, B, A.
What the interviewer checks
3: the list keeps both A entries.
2: the set stores A once.
A: the first item, and the list keeps input order.
Can say that the set has no indexer and promises no order.
Task 2
Question index: Mustmedium · 20 minutes
Write a method that takes a List of strings and returns each that appears more than once, once each, in the order it first became a duplicate. Use a HashSet for what you have seen. Say the complexity, then say what you would change if the caller only needs a yes/no "any duplicates?".
The method and a sample call. Fill in the body; the result for this input is A,B. string.Join puts the items on one line, between commas.
Lending.sln · where this file sits
Stuck? Hints
What does HashSet.Add return when the is already present?
How do you avoid adding the same duplicate twice?
Which collection keeps the order of first repeats?
Show a solution
Lending.sln · where this file sits
Duplicates.cs · line 1 · 1 of 9Returns the repeated in first-repeat order.
What the interviewer checks
Returns A,B for the sample, not A,B,A.
One pass: O(n) average time; the sets use O(n) extra memory.
Uses Add's false return as the duplicate signal, not a nested loop.
For yes/no, returns on the first false and keeps one set only.
Task 3
Question index: Musthard · 25 minutes
Write FirstUnique, which returns the first that appears exactly once, or null when every repeats. Keep the first-in-input order. Then say how you would handle 50 million on a machine with one gigabyte of memory.
The method, a sample call and the expected answer. string? means the method may return no value (null).
Lending.sln · where this file sits
Stuck? Hints
Which collection counts how many times each appears, by key?
Why a second pass over the input instead of a pass over the dictionary?
What does give you when the key is missing?
Show a solution
Lending.sln · where this file sits
FirstUnique.cs · line 1 · 1 of 6The ? says the return may be null: no appears once.
What the interviewer checks
Returns D for the sample, not the first repeated .
One counting pass and one input pass: O(n) time; the dictionary holds O(k) keys.
Uses or , never an indexer read that can throw.
For 50 million and one gigabyte, says a dictionary of that many strings will not fit, and proposes splitting the input by hash into small files (each group small enough to count in memory) or an external sort.
With AI tools
Assistants often write collections code from old examples, so I check four things before I keep a suggestion. First, the item type: an ArrayList or a non-generic collection in a new file is a red flag. Second, the operation: a List.Contains inside a loop over another list is O(n²), and a set or dictionary is usually the fix. Third, equality: a HashSet or Dictionary of a class only works as intended if the class has the right Equals and GetHashCode, and keys do not change after insertion. Fourth, the boundary: a method that returns List<T> lets callers clear the owner's data, so I ask for IReadOnlyList<T> or AsReadOnly. For a speed claim I ask for a Stopwatch or BenchmarkDotNet number, and for a fix I ask for a test with duplicates and a missing value.