A dict of lists against a defaultdict against a Counter; a generator against a list; a dataclass against a tuple. Given the shape and the size of the data, which do you reach for — and what does the wrong choice cost when the input is a thousand times bigger?
The four structures every pipeline is built from, and the traps in each.
Streaming vs materialising
4
When to hold the data and when to let it flow past. The memory decision, made on purpose.
Representing a record
4
Tuple, dict, NamedTuple, dataclass — four ways to hold a row, none of them always right.
Choosing a concurrency model
4
Matching the model to the workload, and what crosses a process boundary.
Structure under scale
4
Where the naive version is O(n·m) and the right structure is O(n+m).
Evergreen · asked verbatim
6
The flat form, in the words interviewers actually use. Same ground as the questions above, asked as recall rather than as a situation.
01 / 26
Data structures & complexity
You have a million `(region, amount)` rows and need the amounts grouped by region. Walk me through the options and pick one.
Why they ask this
It is the most common shape in all of data engineering, and there are four idiomatic ways to write it — one of which is a trap that silently returns partial groups.
Say this
`defaultdict(list)` for almost every case: one pass, O(n), no branch on first sight of a key. `itertools.groupby` only when the input is already sorted by that key, because it groups *consecutive* rows and will happily return the same key several times if it is not.
The reasoning
All the dict-based options are the same algorithm — one pass, hash per row, amortised O(1) insert — and differ only in how they handle the first time a key appears. `dict.get(k, [])` rebuilds and reassigns; `setdefault` is one call but always constructs the empty list even when the key exists; `defaultdict(list)` moves the branch into C. At a million rows the difference is small but real, and `defaultdict` is also the clearest to read.
`itertools.groupby` is the odd one out and the one worth being careful about. It never builds a dict: it walks the iterable and starts a new group whenever the key changes from the previous row. On unsorted input that means `eu, us, eu, us` produces four groups, and if you materialise them into a dict the later ones overwrite the earlier ones — you get the last run of each key and lose everything before it, with no error. The snippet below shows exactly that.
What earns groupby its place is memory. It is streaming: it never holds more than one group, so it is the right tool for a file that is already sorted by the grouping key — which files landing from a warehouse export frequently are — or for data you can afford to sort first. Sorting is O(n log n) and requires materialising, so it is a real trade rather than a free win.
The senior answer names the deciding question: does the whole result fit in memory? If yes, `defaultdict` and stop thinking about it. If no, sort (or arrange for sorted input) and stream with `groupby`, processing each group as it completes — and at that point also consider whether the grouping belongs in Python at all rather than in the warehouse that already has the data.
The formulations
defaultdict(list)ship
from collections import defaultdict
grouped = defaultdict(list)
for region, amount in rows:
grouped[region].append(amount)
One pass, no first-sight branch in Python, and the intent is legible on the first line.
setdefaultworks
grouped = {}
for region, amount in rows:
grouped.setdefault(region, []).append(amount)
Correct and dependency-free, but it constructs a throwaway list on every row, not just new keys.
itertools.groupby on sorted inputworks
from itertools import groupby
rows.sort(key=lambda r: r[0])
for region, group in groupby(rows, key=lambda r: r[0]):
handle(region, [amount for _, amount in group])
The streaming option: constant memory per group, but it costs a sort and the data must stay sorted.
itertools.groupby on whatever arrivesavoid
from itertools import groupby
grouped = {k: list(g) for k, g in groupby(rows, key=lambda r: r[0])}
Silently wrong on unsorted input — it groups consecutive runs, and the dict keeps only the last run.
See it run on CPython 3.12
Same four rows, three approaches. The middle one lost half the data and raised nothing.
from itertools import groupby
from collections import defaultdict
rows = [("eu", 10), ("us", 20), ("eu", 30), ("us", 40)]
grouped = defaultdict(list)
for region, amount in rows:
grouped[region].append(amount)
print("defaultdict:", dict(grouped))
# groupby only ever compares a row with the one before it.
print("groupby unsorted:", {k: [a for _, a in g] for k, g in groupby(rows, key=lambda r: r[0])})
rows.sort(key=lambda r: r[0])
print("groupby sorted: ", {k: [a for _, a in g] for k, g in groupby(rows, key=lambda r: r[0])})
"Use `itertools.groupby`, that is what it is for." It is for grouping *sorted* data. Reaching for it by name without sorting first is the single most common way this task is written incorrectly, and it produces a plausible dict rather than an error.
They’ll ask next
The file is 200 GB and already sorted by region. Does your answer change? What if it is sorted by timestamp instead?
Count how many times each event type appears. Three obvious ways — which do you write, and does the answer change if you also need the top 10?
Why they ask this
Everyone can count. The follow-up about the top N is where it gets interesting, because the naive answer sorts the whole dict when a heap would do.
Say this
`collections.Counter` — it is one call, it is C-level, and `most_common(n)` gives you the top N with a heap rather than a full sort. `defaultdict(int)` is the right answer only when you are accumulating something other than a count.
The reasoning
All three produce identical dicts. `d[k] = d.get(k, 0) + 1` does a lookup and a store per row in Python; `defaultdict(int)` moves the missing-key branch into C; `Counter(iterable)` does the entire loop in C, which is why it is meaningfully faster on large inputs and shorter to read.
`Counter` also brings the operations you usually want next. `most_common(n)` uses `heapq.nlargest` under the hood, so getting the top 10 of a million distinct keys is O(n log 10) rather than the O(n log n) of sorting everything. `Counter` objects add and subtract (`a + b`, `a - b`), which makes combining per-partition counts trivial, and `total()` gives the sum.
The behaviour to know is that indexing a missing key returns 0 *without* inserting it — unlike `defaultdict`, which inserts. That makes `Counter` safe to probe (`counts["signup"]` is 0 and the key stays absent), which matters when you go on to iterate the keys.
Where `defaultdict(int)` genuinely wins is when the value is not a count: summing amounts, tracking a maximum, accumulating into a set or list. And where all of them lose is when the cardinality is huge — a hundred million distinct keys is a hundred million Python objects, at which point the honest answer is that the aggregation belongs in the warehouse, or in a sketch like HyperLogLog if approximate is acceptable.
The formulations
Countership
from collections import Counter
counts = Counter(events)
top10 = counts.most_common(10)
The whole loop runs in C, and most_common uses a heap rather than sorting every key.
defaultdict(int)works
from collections import defaultdict
counts = defaultdict(int)
for e in events:
counts[e] += 1
The right shape when the value is not a count — a sum, a max, or a set you accumulate into.
dict.getworks
counts = {}
for e in events:
counts[e] = counts.get(e, 0) + 1
No import and no surprises; just the slowest of the three and the noisiest to read.
Sorts every distinct key to take ten of them — O(n log n) where most_common is O(n log 10).
See it run on CPython 3.12
The last line is the difference from defaultdict: reading a missing key does not create it.
The answer most people give
"Counter is just a dict subclass, so it makes no difference." It is a dict subclass whose constructor loops in C and whose `most_common` uses a heap. On a million rows both of those are measurable, and the second one changes the complexity class.
They’ll ask next
A hundred million distinct keys, and you need exact counts. Is Counter still your answer?
You have to check whether each of a million ids appears in a reference collection of 100,000. What do you build the reference as, and what constrains that choice?
Why they ask this
It is the clearest complexity question in everyday Python — the difference between the two answers is 100,000× on the inner operation — and the constraint on the fast one (hashability) is the part people forget.
Say this
A `set`. `x in list` is a linear scan, O(n) per check, so a million checks against 100,000 entries is 10^11 comparisons; `x in set` is one hash, O(1). The constraint is that the members must be hashable, which rules out lists and dicts.
The reasoning
A list stores items in order and knows nothing about their values, so `in` walks it until it finds a match — averaging half the list per hit, all of it per miss. A set stores items in a hash table, so `in` computes one hash and looks at one bucket. Going from 10^11 comparisons to 10^6 hashes is not an optimisation, it is the difference between an overnight job and a two-second one.
The cost is building the set: one pass and roughly 32–60 bytes per entry, which is real but almost always the right trade the moment you do more than a handful of lookups. The rule of thumb worth saying out loud is that a lookup structure pays for itself at about the point where the number of lookups exceeds a small constant, so the only case where the list is fine is a genuinely one-off check.
The constraint is hashability: members must implement `__hash__`, which means immutable in practice. Lists and dicts raise `TypeError: unhashable type` — so if your key is a composite, use a tuple `(dataset, day)` or a frozen dataclass, not a list. This is the same requirement dict keys have, for the same reason.
Two more things worth knowing: use a `dict` rather than a `set` if you need the matched row and not just a yes/no, because the lookup cost is identical and you avoid a second pass; and a set silently deduplicates, which is usually what you want but will change your counts if you were relying on the reference collection having repeats.
The formulations
setship
lookup = set(reference_ids)
matches = [i for i in incoming if i in lookup]
One hash per check. Build cost is one pass, and it pays back after a handful of lookups.
dict, when you need the rowship
by_id = {r["id"]: r for r in reference_rows}
enriched = [(i, by_id[i]) for i in incoming if i in by_id]
Same lookup cost as a set, and it hands back the matching record instead of a boolean.
listavoid
matches = [i for i in incoming if i in reference_ids]
A linear scan per check; correct, and quadratic in a way that only shows up on real data volumes.
See it run on CPython 3.12
The constraint, demonstrated: a list cannot be a member, a tuple can — and dedups.
The answer most people give
"Sets are faster than lists." For membership, yes — for iteration they are comparable, and for anything order-dependent a set is simply wrong. The answer is about which operation you are doing, not about one type being better.
They’ll ask next
Your key is `(customer_id, day)` and it is currently a list. What do you change, and does anything about ordering break?
Your `defaultdict(list)` has keys in it that were never written to, and a downstream count of "regions seen" is too high. How did that happen?
Why they ask this
It is the one behaviour of `defaultdict` that surprises people, and it produces a wrong number rather than an exception — the failure mode this whole bank is organised around.
Say this
Reading a missing key from a `defaultdict` inserts it. Any `if d[k]:` or `d[k]` probe creates an empty entry, so iterating the keys afterwards reports regions you only ever asked about. Use `.get(k)` to read without inserting.
The reasoning
`defaultdict` implements `__missing__`, which the subscript operator calls when the key is absent. It runs the factory, *stores* the result, and returns it. That is exactly what makes `d[k].append(v)` work in one line — the list has to be stored for the append to persist — and it means there is no way to distinguish reading from writing through the subscript.
So a perfectly reasonable-looking probe mutates the structure. `if seen["us"]:` creates `"us": []`, and now `"us" in seen` is True, `len(seen)` is one higher, and iterating the keys yields a region that never appeared in the data. Nothing raises, and the error surfaces later as a count that is slightly wrong.
`.get(k)` goes through `__getitem__`'s sibling path and never inserts, so it is the correct way to probe. `k in d` is also safe. The habit worth forming is to use subscript only where you intend to write, and `.get` everywhere you intend to read — which is good practice with a plain dict too, and mandatory here.
This is also why passing a `defaultdict` out of a function is a mild code smell: the caller has no idea that reading it changes it. Converting with `dict(grouped)` at the boundary costs one pass and hands back something that behaves the way every reader expects.
See it run on CPython 3.12
The `if` statement on line 7 is a read. Line 8 shows what it left behind.
The answer most people give
"Something must be writing empty lists into it." Nothing is. The read itself is the write — and until you know `__missing__` stores its result, that line looks entirely innocent in review.
They’ll ask next
How would `Counter` behave in the same probe? Why is it different?
A 40 GB CSV on a box with 8 GB of RAM: sum one column, grouped by another. How do you structure the code?
Why they ask this
It is the question that separates people who have processed data at size from people who have only processed samples. The structural answer — a chain of generators — is also the one that happens to be most testable.
Say this
A chain of generator functions: read lines, parse, filter, then aggregate into a dict as you go. Peak memory is one row plus the result, so the file size stops being relevant — and each stage is independently unit-testable because it is a function over an iterable.
The reasoning
Iterating a file object already yields one line at a time, so the memory question is entirely about what you do with them. `f.readlines()` or `list(reader)` materialises everything and is the only real mistake available here. Everything else follows from keeping the data moving.
Structuring it as separate generator functions — `read → parse → filter` — costs nothing at run time, because a generator holds a resume point rather than a buffer, and buys two things. The whole pipeline occupies a couple of hundred bytes regardless of the input size, and each stage can be tested by handing it a three-element list, with no file and no fixtures.
The part that does grow is the aggregate. Summing by region is bounded by the number of distinct regions, which is fine. Summing by `customer_id` across 200 million customers is not, and that is the point at which the honest answer changes: sort by key and aggregate in a streaming pass, or spill to disk, or push the aggregation into DuckDB or the warehouse. Knowing which of your dicts is bounded is the actual skill.
Two practical notes that come up as follow-ups. Use `csv.reader` rather than `line.split(",")` unless you can guarantee no quoted commas, because the naive version is wrong for real CSV. And open with an explicit `encoding` and `newline=""`, because the default encoding is platform-dependent and will decode differently on someone else's machine.
The formulations
Chained generatorsship
totals = defaultdict(int)
for region, amount in parse(read_rows(open(path))):
totals[region] += amount
One row in flight at a time; peak memory is the result dict, not the file.
readlines()avoid
for line in open(path).readlines():
region, amount = line.split(",")
Materialises 40 GB to iterate it once — the only genuinely wrong option on the list.
pandas with chunksizeworks
for chunk in pd.read_csv(path, chunksize=500_000):
totals = totals.add(chunk.groupby("region")["amount"].sum(), fill_value=0)
Bounded memory and vectorised per chunk; worth it when the per-row work is real arithmetic.
Hand it to DuckDBship
duckdb.sql("SELECT region, sum(amount) FROM read_csv(?) GROUP BY 1", [path])
Out-of-core, parallel and in C. If the task is really an aggregation, Python is the wrong layer.
See it run on CPython 3.12
Three stages composed, nothing executed until `sum` pulls. The whole chain is 208 bytes.
The answer most people give
"Read it in chunks of 10,000 lines." That works, and it is a heavier answer than the problem needs — the file object already streams line by line. Chunking earns its place when you are batching *writes* or vectorising, not for reading.
They’ll ask next
Now group by `customer_id` instead of region, and there are 200 million customers. What breaks, and what do you do?
Given that streaming is the default, when should you deliberately call `list()` on an iterable?
Why they ask this
The reverse of the usual question, and better for it. Someone who has internalised "generators good" without knowing when they fail will write a function that quietly returns nothing on its second use.
Say this
When you need more than one pass, a length, random access, sorting, or a stable object to hand to a caller — and when you know the data fits. Calling `list()` turns an invisible bug into an explicit memory decision.
The reasoning
The three things a generator cannot do are the three reasons to materialise. It has no `len()` — the object does not know how many items it will produce. It has no indexing, so `rows[0]` is a `TypeError`. And it is single-pass, so a second consumer sees nothing at all, which is the dangerous one because it returns an empty result rather than raising.
Sorting also forces it, and there is no way around that: `sorted()` must see every element before it can emit the first, so it materialises internally regardless of what you pass in. Same for `max`, `min` and `sum` — those consume the whole thing but only hold one value, so they are fine on a generator; it is specifically the operations that need the *collection* that require the memory.
The API-design case is the one worth raising unprompted. A function that returns a generator has handed the caller something they can consume exactly once, and if they store it on an object and read it twice, the second read is empty. Returning a list is the safer public contract; returning a generator is right when the caller is a pipeline stage that you know consumes it once. Say which one you mean in the type hint — `Iterator[Row]` versus `list[Row]` is a real difference.
The way to phrase the decision in an interview: materialising is not a failure, it is a choice with a stated cost. "I will call `list()` here because I need two passes and the reference data is 50,000 rows" is a good answer. Materialising by reflex, and only finding out the size in production, is not.
See it run on CPython 3.12
The first two failures are loud. The third is the one that ships.
The answer most people give
"Never — always stream." Sorting, counting and any second pass require the collection. An answer that refuses to materialise ends up calling `sum(1 for _ in rows)` to get a length and then finding the rows gone.
They’ll ask next
Your function signature is `-> Iterator[Row]` and a caller loops it twice. Whose bug is it?
Deduplicate a stream of 50 million ids. A `set` is the obvious answer — at what point does it stop being the right one, and what replaces it?
Why they ask this
It forces you to put a number on the memory cost of the structure you reached for, which is the difference between knowing an algorithm and being able to size it.
Say this
A set is right until it does not fit. The container alone is about 32 MB per million entries, plus an object per id — so 50 million ints is several gigabytes. Past that: sort and dedupe adjacent, partition by hash across workers, or accept approximation with a Bloom filter.
The reasoning
Start with the number. A set of a million ints reports about 33 MB for the hash table itself, and each `int` object is another 28 bytes; strings are worse, at roughly 50 bytes plus the characters. So 50 million integer ids is on the order of 3–4 GB before any of your other data — fine on a 32 GB box, fatal in a 2 GB container, and the whole point is that you should be able to work that out rather than discover it.
When it fits, a set is unambiguously right: one hash per id, O(1) membership, and it streams — you never hold the input, only the distinct keys. If you need to preserve first-seen order, `dict.fromkeys(ids)` does the same job and keeps insertion order, for slightly more memory.
When it does not fit, the options are ordered by how much you are willing to give up. Sort the ids externally and drop adjacent duplicates in one streaming pass — exact, O(n log n), and bounded memory because the sort spills to disk. Or partition by `hash(id) % k` into k files and dedupe each independently, which is exact and parallel and is precisely what a distributed engine does for you. Or, if a small false-positive rate is acceptable, a Bloom filter answers membership in a couple of bits per element — but it can only say "definitely not seen" or "probably seen", so it is suitable for skipping work, not for correctness.
The framing that lands well: dedup at this size is a question about where the state lives. In memory it is a set; on disk it is a sort; across machines it is a partition key; and if you are already writing to a warehouse or an ACID table format, the deduplication is a MERGE and none of this is your problem.
See it run on CPython 3.12
One million ints. The container is 33 MB before you count the objects it points at.
The answer most people give
"Use a set, it is O(1)." True and incomplete — O(1) time says nothing about space, and space is the thing that fails here. An answer that never mentions the memory has not engaged with the question.
They’ll ask next
The ids are 36-character UUID strings rather than ints. How does your estimate change?
You are streaming rows and the sink accepts at most 1,000 per request. How do you batch a generator without materialising it?
Why they ask this
Every API and every bulk loader has a batch limit, so this is real code everyone writes. It also probes whether you know `itertools` rather than reaching for an index-based loop that a generator cannot support.
Say this
`itertools.batched(rows, 1000)` on Python 3.12+, or the `iter`/`islice` loop on anything older. Both pull exactly one batch at a time, so a stream of any length works with a batch in memory.
The reasoning
The instinct is `rows[i:i+1000]` in a loop, and it cannot work on a generator — there is no indexing and no length. What you need is something that pulls n items and stops, which is `islice`. Wrapping the source in `iter()` first is the essential detail: `islice` must resume from where the last batch stopped, and it can only do that if it is slicing an *iterator* rather than restarting on the iterable each time.
`while chunk := list(islice(stream, n)):` is the idiom, and it terminates naturally because the final partial batch is truthy and the one after it is empty. Python 3.12 added `itertools.batched`, which does the same thing in C and yields tuples — shorter and slightly faster, with the same laziness.
The thing to say about batch size: it is a trade between request overhead and blast radius. Bigger batches amortise the round trip, and they also mean a single failure invalidates more work and a retry re-sends more rows. If the sink is not idempotent, batch boundaries are also your retry boundaries, so the size is a correctness parameter and not only a performance one.
Two related habits: attach a batch identifier so a retry can be recognised as a duplicate downstream, and make sure the final partial batch is flushed — a loop that only writes when the buffer reaches exactly n silently drops the tail, which is a genuinely common bug and one that only shows up when the row count is not a multiple of the batch size.
The formulations
itertools.batched (3.12+)ship
from itertools import batched
for chunk in batched(rows, 1000):
sink.write(list(chunk))
Standard library, C-level, lazy, and it handles the trailing partial batch for you.
iter + isliceship
stream = iter(rows)
while chunk := list(islice(stream, 1000)):
sink.write(chunk)
The portable version for pre-3.12; the iter() call is what makes islice resume rather than restart.
Manual accumulatorworks
buffer = []
for row in rows:
buffer.append(row)
if len(buffer) == 1000:
sink.write(buffer); buffer = []
if buffer:
sink.write(buffer)
Fine, and the trailing `if buffer` is the line people forget — which drops the last partial batch.
Slicing the sourceavoid
for i in range(0, len(rows), 1000):
sink.write(rows[i:i + 1000])
Requires a sequence, so it forces the whole stream into memory before the first write goes out.
See it run on CPython 3.12
Both yield the same batches, and both leave the trailing partial batch intact.
The answer most people give
"Slice the list into chunks of 1,000." That needs a list, which is exactly what streaming was avoiding — and on a 40 GB source it fails before the first request is sent.
They’ll ask next
A batch of 1,000 fails halfway through on the server side. What does your retry send, and how does the sink know it is a duplicate?
A row moves through five stages of your pipeline. Do you carry it as a tuple, a dict, a NamedTuple or a dataclass — and what decides?
Why they ask this
Every pipeline makes this choice, usually by accident, and living with it for a year is what teaches you the trade-offs. The answer reveals whether you have maintained a pipeline or only written one.
Say this
Dict at the boundaries where the schema is genuinely unknown; dataclass in the middle where you own the shape and want names, types and validation; NamedTuple when the row must stay a tuple for interop or memory. A bare tuple only for short-lived, obviously-positional pairs.
The reasoning
The axis that matters is how a mistake fails. With a tuple, `row[3]` is silently wrong when someone inserts a column — no error, just the wrong field, forever. With a dict, `row["custmer_id"]` is a `KeyError` at run time, which is late but at least loud. With a dataclass or NamedTuple, a bad field name is caught by the type checker and by your editor before the code runs.
Dicts earn their place at the edges. JSON arrives as a dict, the schema may vary between vendors, and forcing it into a typed object before you have validated it just moves the failure. The pattern that works is: parse to a dict, validate, construct a dataclass, and let everything downstream of that boundary be typed — so the untyped region is small and named.
Between dataclass and NamedTuple: a NamedTuple *is* a tuple, so it unpacks, compares and indexes like one, and it is immutable and hashable without asking. That makes it excellent for keys and for interop with code that expects positional rows. A dataclass is mutable by default, supports `field(default_factory=...)`, methods, inheritance and `slots=True`, and reads better when the record has behaviour rather than just fields.
The one to be wary of is the dict-as-record habit in the middle of a long pipeline. It is fast to write and it defers every schema question to run time, so five stages later nobody can say what keys a row has without reading all five. That is exactly the cost a dataclass removes, and the reason it is worth the extra ten lines.
The formulations
dataclassship
@dataclass
class Order:
id: int
region: str
amount: Decimal
Named, typed, checkable before it runs, and the definition documents the row for every reader.
NamedTupleship
class Order(NamedTuple):
id: int
region: str
amount: Decimal
All of the above plus immutable, hashable and tuple-compatible — the right key type.
dictworks
order = {"id": 1, "region": "eu", "amount": Decimal("10")}
Correct at a boundary where the schema really is unknown; a liability five stages later.
bare tupleavoid
order = (1, "eu", Decimal("10"))
region = order[1]
Every read is a positional guess, and inserting a column breaks callers without raising anything.
See it run on CPython 3.12
The same row four ways. The last line is why a NamedTuple can replace a tuple in place.
The answer most people give
"Dicts, always — they are flexible." Flexible means nothing checks them. In a five-stage pipeline that flexibility is how a renamed field reaches stage five before anyone finds out.
They’ll ask next
You have 50 million rows in memory at once. Does your answer change, and by how much?
You are holding 50 million small objects in memory and it will not fit. What does `slots=True` change, and what does it cost you?
Why they ask this
It is the follow-up to the record-shape question and the point where "which type" becomes a sizing calculation. Knowing that the saving comes from removing `__dict__` — and what that forbids — is the whole answer.
Say this
By default every instance carries a `__dict__`, which is where the per-object overhead lives. `slots=True` replaces it with a fixed array of descriptors, cutting the per-instance footprint several-fold and making attribute access slightly faster. The cost is that you cannot add attributes that were not declared.
The reasoning
A normal instance stores its attributes in a per-object dictionary, which is flexible and expensive: the dict has its own header, hash table and growth slack, and there is one per object. `__slots__` — which `@dataclass(slots=True)` generates for you — declares the attribute names up front so the interpreter can lay them out in a fixed array on the object itself and skip the dict entirely.
The measured difference on the two-field class below is 296 bytes against 32. Multiply by 50 million and it is the difference between roughly 15 GB and 1.6 GB, which is exactly the kind of number that decides whether a job needs a bigger box. Attribute access also gets marginally faster, because it is an index rather than a hash lookup.
What you give up is dynamic attributes: assigning anything not declared raises `AttributeError`. That is usually a feature — it catches typo'd attribute names at the point of assignment rather than leaving a silently-ignored field — but it breaks code that monkey-patches instances or attaches ad-hoc metadata, and it interacts awkwardly with multiple inheritance if two parents both define slots. Note also that `weakref` support goes away unless you add `__weakref__` explicitly.
The honest framing: slots are a memory optimisation, so apply them when memory is the constraint you have measured, not by default. And if you are holding 50 million rows to do columnar arithmetic on them, the better answer may be not to hold objects at all — arrays, pyarrow tables or a dataframe store the same data in a fraction of the space because they do not pay per-row object overhead at all.
See it run on CPython 3.12
Two identical dataclasses. The only difference is where the attributes live.
The answer most people give
"Add `__slots__` to every class, it is free performance." It is not free — it removes dynamic attributes and weak references, and on a class you instantiate a hundred times it saves nothing worth the constraint.
They’ll ask next
You need per-row arithmetic across all 50 million. Is a slotted dataclass still the right container?
Your dataclass declares `rows: int` and a caller passes the string "12". What happens, and where should the check live?
Why they ask this
A large fraction of Python engineers believe annotations are enforced. This question finds that out immediately, and the good answer leads straight into where validation belongs in a pipeline.
Say this
Nothing happens — annotations are metadata, not checks, and the object is constructed with a string in an int field. Validate once at the boundary where untrusted data enters, with `__post_init__`, an explicit parser or a library like pydantic, and trust the types everywhere inside.
The reasoning
At run time, `rows: int` puts an entry in `__annotations__` and nothing else. The interpreter never compares the value to the annotation. A type checker will flag the call if it can see it, but data arriving from a file, an API or a queue is invisible to static analysis by definition — which is exactly where wrong types come from.
The consequence is a wrong value travelling a long way. `Partition(dataset=123, rows="not a number")` constructs happily, prints plausibly, and fails later at something like `rows + 1` — three stages away, with a traceback pointing at innocent code. Or it never fails at all and gets written to a warehouse column that quietly accepts it.
Validation belongs at the boundary, once, where the data crosses from untrusted to trusted. In a dataclass that is `__post_init__`, which runs after the generated `__init__` and is the natural place for both type checks and business rules ("amount must not be negative"). At a bigger boundary, pydantic or attrs earn their dependency: they generate the checking from the same annotations, coerce where you ask them to, and produce error messages that name the field and the row.
The pattern to describe is a narrow validated edge and a typed interior. Parse to dicts, validate and construct typed records at the entry point, and let every function downstream declare and trust its types. That way there is exactly one place that deals with malformed input, and it is the place with the context to report which file and which line it came from.
See it run on CPython 3.12
Constructed with two wrong types and no complaint. The explicit check is the one that fires.
The answer most people give
"mypy will catch it." mypy catches what it can see. The value here came from a JSON file at run time, which no static checker can inspect — that is precisely the case where annotations give you nothing.
They’ll ask next
Would you validate every row, or sample? What does per-row pydantic validation cost at ten million rows a batch?
You are deduplicating on `(customer_id, event_day)`. What do you use as the key — a formatted string, a tuple, or something else?
Why they ask this
Composite keys are unavoidable in data work, and the string-concatenation version is both the most common and the one that breaks on real data in a way that is very hard to find.
Say this
A tuple, or a frozen dataclass when the key travels far enough that the field names matter. Never a concatenated string: the delimiter eventually appears inside a value and two different keys collide into one.
The reasoning
A tuple of the components is hashable, compares element by element, and cannot collide — `("a-b", "c")` and `("a", "b-c")` are distinct, where `"a-b-c"` cannot tell them apart. It is also faster, because it skips the formatting, and it sorts sensibly for free.
The string version is worth understanding rather than just rejecting, because the failure is subtle. `f"{customer_id}-{day}"` is fine until a customer id contains your delimiter — and ids from an upstream vendor eventually do. Two distinct keys map to one string, the dedup silently merges two customers' events, and the resulting number is wrong in a way no test with clean fixtures will find.
A frozen dataclass is the tuple with names. It costs a little more memory and buys self-documenting call sites: `Key(customer_id=..., event_day=...)` cannot be built with the fields in the wrong order, which a two-string tuple absolutely can. Use it when the key is constructed in several places or passed across module boundaries; use a plain tuple when it is local to one function.
Two details worth stating. Normalise before keying — trim whitespace, case-fold, and parse dates to a `date` rather than keeping whatever string format arrived — because `"2026-03-01"` and `"2026-3-1"` are different keys and the same day. And keep `None` out of keys, since a NULL customer id groups every unknown customer together, which is almost never what you want.
The formulations
tupleship
seen = set()
for row in rows:
key = (row.customer_id, row.event_day)
if key in seen:
continue
seen.add(key)
Hashable, collision-free, ordered for sorting, and cheaper than formatting a string.
frozen dataclassship
@dataclass(frozen=True)
class DedupKey:
customer_id: str
event_day: date
A tuple with names, so the fields cannot be swapped at a call site; worth it once it travels.
concatenated stringavoid
key = f"{row.customer_id}-{row.event_day}"
Collides the day a value contains the delimiter, merging two keys with no error anywhere.
The answer most people give
"A string key is fine as long as you pick an unusual delimiter." That makes the collision rarer, not impossible, and moves the failure to a day when nobody is looking at this code. A tuple removes the failure mode rather than shrinking it.
They’ll ask next
Where would you normalise `event_day` — at parse time or at key time? What goes wrong if two stages normalise differently?
Concurrency (threading vs multiprocessing vs asyncio)APIs & pagination
Fetch 500 pages from a paginated API, each taking about 200 ms. Serial takes 100 seconds. What do you use, and how do you size it?
Why they ask this
It is the most common concurrency task in data engineering and it has three defensible answers. The signal is whether you can justify the choice and name the limit that actually binds — which is usually the remote service, not your machine.
Say this
`ThreadPoolExecutor` with a bounded pool of 10–20. The work is I/O-bound so the GIL is released while waiting, and threads work with ordinary blocking clients. Size it to the API's rate limit, not to your core count.
The reasoning
The work is waiting, not computing, so the GIL is irrelevant — a thread blocked on a socket has released it. A pool of 20 turns 100 seconds into roughly 5, and that number is set by concurrency, not by CPU.
The sizing question is the interesting half. More threads stop helping the moment you saturate the remote service, and past that you are generating 429s and getting throttled — which is slower than the smaller pool would have been. So the real limit is the API's documented rate limit, and the polite implementation respects `Retry-After` and backs off exponentially with jitter rather than hammering. Bounding the pool also bounds memory, since each in-flight response is held.
Asyncio with `httpx` or `aiohttp` is the other good answer and scales further — thousands of concurrent requests cost coroutines rather than thread stacks. It is the right choice for a service already built on an event loop or for genuinely high fan-out. For a batch job doing 500 calls with a blocking client, it is a rewrite that buys nothing measurable, and one synchronous call left in the path silently removes the benefit.
A process pool is the wrong tool: you would pay process start-up and pickling for workers that spend all their time asleep. And whichever model you pick, the pagination detail matters more than the concurrency — if pages must be fetched in sequence because each response carries the next cursor, you cannot parallelise the walk at all. You parallelise across partitions, date ranges or keys instead, which is a design decision made before the executor.
The formulations
ThreadPoolExecutor, boundedship
with ThreadPoolExecutor(max_workers=16) as pool:
for page in pool.map(fetch, urls):
handle(page)
Works with any blocking client, releases the GIL while waiting, and the bound protects the API.
asyncio + httpxship
async with httpx.AsyncClient() as client:
sem = asyncio.Semaphore(16)
pages = await asyncio.gather(*(fetch(client, sem, u) for u in urls))
Scales to thousands of connections; needs the whole call path to be async to pay off.
ProcessPoolExecutoravoid
with ProcessPoolExecutor() as pool:
pages = list(pool.map(fetch, urls))
Pays process start-up and pickling for workers that only ever wait on a socket.
Unbounded threadsavoid
threads = [Thread(target=fetch, args=(u,)) for u in urls]
for t in threads: t.start()
500 simultaneous requests earns you a rate limit, and 500 stacks for work that needed 16.
The answer most people give
"Use multiprocessing to get real parallelism." Real parallelism is not the constraint — the constraint is 200 ms of waiting per call. Processes make the waiting cost more, not less.
They’ll ask next
Each response must be written to Postgres through a blocking driver. Which of your two good answers survives that?
Concurrency (threading vs multiprocessing vs asyncio)GIL & memory
Parsing and normalising 40 GB of JSON is CPU-bound. You reach for `ProcessPoolExecutor` and it comes out slower than the serial version. Why might that be?
Why they ask this
The naive multiprocessing answer is right in principle and frequently slower in practice. Knowing why — the cost of crossing the process boundary — is what makes the answer usable.
Say this
Because everything sent to a worker and returned from it is pickled and copied. Submitting a million tiny tasks means the transfer dominates the work. Chunk it — send file paths or byte ranges, not rows — so each task is large enough to pay for its own overhead.
The reasoning
A process pool has three costs the serial version does not: starting the workers, pickling each argument and result, and copying those bytes through a pipe. Parallel speedup only appears when the per-task compute is large relative to that overhead. A task that parses one 200-byte row spends more time in transit than in work, and the pool loses.
The fix is granularity. Give each worker a unit of work that is meaningful on its own — one file, one partition, one byte range — so the argument is a path (tiny to pickle) and the compute is seconds rather than microseconds. If you must map over rows, pass a large `chunksize` so the pool batches them; the default of 1 for `ProcessPoolExecutor.map` is exactly the pathological case.
Return values matter as much as arguments. A worker that parses a file and returns a million dicts pickles all of them back to the parent, and you have simply moved the memory problem while adding a serialisation step. The pattern that works is for each worker to write its own output — one file per partition — and return a small summary: path, row count, checksum. The parent then does no heavy lifting at all.
The other answer worth giving is to leave Python. JSON parsing is the archetypal case where a C library wins outright: `orjson` is several times faster single-threaded, and pyarrow or DuckDB will read the files in parallel with the GIL released and no pickling at all. Multiprocessing is what you use when the hot work genuinely must be Python.
The formulations
One task per fileship
with ProcessPoolExecutor() as pool:
summaries = list(pool.map(parse_file_to_parquet, paths))
Tiny arguments, big compute, small returns — the shape process pools are actually good at.
Rows with a large chunksizeworks
pool.map(normalise, rows, chunksize=10_000)
Amortises the transfer across a batch; still copies every row both ways.
A C library insteadship
table = pyarrow.json.read_json(path) # parses in C, releases the GIL
Removes the problem rather than parallelising it, and needs no process boundary at all.
One task per rowavoid
with ProcessPoolExecutor() as pool:
parsed = list(pool.map(normalise, rows))
Pickles every row out and back for microseconds of work; reliably slower than serial.
The answer most people give
"Multiprocessing is slower because of the GIL." Each process has its own GIL, so the GIL is precisely what multiprocessing escapes. The cost is serialisation and copying across the process boundary.
They’ll ask next
Your worker returns a 2 GB list of parsed rows. What happens in the parent, and what would you return instead?
Concurrency (threading vs multiprocessing vs asyncio)Error handling & retries
With an executor, when do you use `pool.map` and when do you use `as_completed`? What does each guarantee about ordering?
Why they ask this
It decides whether your pipeline stalls behind its slowest item. People who have only used `map` tend not to know there is a choice, and it is the difference between processing results as they arrive and waiting for the tail.
Say this
`map` yields results in *input* order, so a slow first item blocks everything behind it even after they finish. `as_completed` yields futures in *completion* order, so you can start handling results immediately. Use `map` when order matters, `as_completed` when latency does.
The reasoning
Both run the same tasks with the same parallelism — the difference is purely in how results are handed back. `map` preserves correspondence with the input, which is what you want when you are zipping results against the inputs that produced them or writing an ordered output file. The cost is head-of-line blocking: if the first task takes 30 seconds and the rest take one, you get nothing for 30 seconds and then everything at once.
`as_completed` takes a collection of futures and yields each as it finishes. That lets downstream work overlap with the remaining fetches, which matters when you are writing results to a sink or streaming them onward. You lose the input correspondence, so if you need to know which input a result came from, keep a `{future: input}` dict and look it up — that is the standard idiom and it is worth being able to write from memory.
Error handling differs too, and it is the part that catches people. With `map`, an exception in a task is re-raised when you reach that result in the iteration, so tasks after it never surface. With `as_completed`, calling `future.result()` raises for that one future and the loop continues — which makes partial success much easier to handle. For a batch of 500 fetches where a handful will fail, that is a real advantage.
The snippet makes the ordering concrete: three tasks whose durations are the reverse of their submission order come back reversed under `as_completed` and in submission order under `map`. Note that `map` returns a lazy iterator, so nothing is yielded until you consume it — but every task starts as soon as it is submitted either way.
See it run on CPython 3.12
Pages 3, 2, 1 submitted in that order; page 3 is the slowest. Order of results is the only claim here.
The answer most people give
"`as_completed` is faster." Both finish at the same moment — the last task decides that. What changes is when you get the *first* result, and whether one failure hides the results behind it.
They’ll ask next
With `as_completed`, how do you know which input a given future came from?
Concurrency (threading vs multiprocessing vs asyncio)GIL & memory
Your `ProcessPoolExecutor` call fails with a pickling error the moment you pass a lambda or a closure. Why, and what do you pass instead?
Why they ask this
It is the first wall everyone hits when moving from threads to processes, and the explanation — that the argument has to be reconstructible by name in a fresh interpreter — is what makes the workaround obvious rather than magic.
Say this
Arguments and return values are pickled to reach another process, and pickle stores functions by qualified name rather than by code. A lambda has no importable name, so it cannot be reconstructed. Use a module-level function, `functools.partial`, or a small callable class.
The reasoning
Threads share memory, so passing a closure to a thread is free — it is the same object. A process does not: the argument is serialised, sent through a pipe, and rebuilt on the other side. Pickle handles functions by writing down "module X, name Y" and importing that at the far end, which works for anything defined at module level and fails for lambdas, nested functions and anything else without a stable import path.
The error message names it directly — `PicklingError`, "attribute lookup <lambda> on __main__ failed" — because pickle went looking for the name and there was nothing there. The same restriction hits open file handles, database connections, sockets and locks: they are not meaningful in another process, so either they refuse to pickle or, worse, they arrive as something that looks usable and is not.
The workarounds in order of preference: define the worker at module level and pass data as arguments; use `functools.partial(fn, config)` to bind extra parameters, since a partial of a module-level function pickles fine; or make the worker a small class with `__call__` and picklable state. If the worker genuinely needs a connection, open it *inside* the worker — a lazily-initialised per-process client is the standard pattern, and it is also what you want for connection pooling anyway.
This constraint is the reason worker functions end up looking like pure functions of plain data, which is a good outcome. It also explains why `if __name__ == "__main__":` is mandatory under the spawn start method: the child re-imports your module, and without the guard it would re-execute the pool creation and fork bomb itself.
See it run on CPython 3.12
Identical work, two ways of passing it. Only the one with an importable name survives the trip.
The answer most people give
"Use `dill` instead of pickle and it works." That does fix the serialisation, and it leaves the real question untouched: whether the thing you are shipping to a worker should be a closure over parent state at all. Usually the answer is data plus a named function.
They’ll ask next
Your worker needs a database connection. Where do you create it, and why not pass it in?
Enrich 10 million orders with a 100,000-row customer table, in Python. How do you structure the join?
Why they ask this
The nested-loop version is the natural way to write it and it is a trillion comparisons. This is the clearest place to show that choosing a structure is choosing a complexity class.
Say this
Build a dict index on the smaller side keyed by the join key, then stream the larger side through it. That is O(n + m) with one hash per row, against O(n·m) for the nested loop — 10.1 million operations instead of 10^12.
The reasoning
The nested loop compares every order against every customer, so the work is the product of the sizes. At 10 million and 100,000 that is a trillion comparisons — hours to days. Indexing the small side turns each order into a single hash lookup, so the total is one pass to build the index plus one pass over the orders.
Which side to index is decided by memory: build on whichever fits, stream the other. That is exactly what a database calls a hash join, and the "build side" and "probe side" language transfers directly — worth using in an interview, because it shows you know you are hand-implementing something the engine does for you.
The join semantics still have to be chosen deliberately, and this is where Python code silently differs from SQL. `index[key]` raises `KeyError` on a miss, which is an inner join that crashes; `index.get(key)` gives you a left join with `None`; and if the build side has duplicate keys, `{c["id"]: c for c in customers}` keeps only the last one and silently drops the rest. If duplicates are legitimate you need `defaultdict(list)` and a nested loop over the matches — which is the fan-out that inflates row counts, exactly as it does in SQL.
The senior point to land: if both sides are large enough that neither indexes comfortably, stop. Sort-merge both sides on the key and walk them in lockstep, or partition by `hash(key) % k` and join partition-wise, or accept that you are re-implementing a database and push the join into DuckDB, Spark or the warehouse. Joining 10 million against 10 million in a Python dict is possible and is rarely the right call.
The formulations
Dict index, probe the large sideship
index = {c["id"]: c for c in customers}
for order in orders:
customer = index.get(order["customer_id"])
One hash per order; the small side sits in memory and the large side never has to.
defaultdict(list) when keys repeatship
index = defaultdict(list)
for c in customers:
index[c["id"]].append(c)
The honest version when the build side is not unique — and it makes the fan-out visible.
Sort-mergeworks
orders.sort(key=itemgetter("customer_id"))
customers.sort(key=itemgetter("id"))
# walk both cursors forward together
The answer when neither side fits in memory; costs two sorts and needs both streams ordered.
Nested loopavoid
for order in orders:
for c in customers:
if order["customer_id"] == c["id"]:
...
Correct and O(n·m) — a trillion comparisons at these sizes, for work a dict does in 10 million.
See it run on CPython 3.12
Ten orders, five customers. Same answer, five times the work — and the ratio grows with the product.
The answer most people give
"Load both into pandas and use `merge`." That is often the right practical answer and it is not an answer to this question — the interviewer is asking whether you know why the merge is fast. It also materialises both sides, which is the constraint the question is built around.
They’ll ask next
The customer table has two rows per id because it is SCD2 history. What does your dict do, and what should it do?
You need the 10 largest orders out of 50 million. Do you sort?
Why they ask this
Sorting to take a handful is the reflex answer, and the heap version is both faster and — more importantly — bounded in memory, which is what makes it work on a stream.
Say this
No. `heapq.nlargest(10, rows, key=...)` keeps a heap of 10 and is O(n log k) in time and O(k) in space, so it works on a generator. Sorting is O(n log n) and has to materialise all 50 million rows first.
The reasoning
The space difference is the one that decides it. `sorted(rows)[:10]` must hold every row to sort it, so 50 million rows have to fit in memory before you discard 49,999,990 of them. `nlargest` holds ten: it pushes each incoming row onto a bounded heap and pops the smallest, so it consumes an iterator and never grows. That is the difference between a job that needs a large instance and one that runs anywhere.
Time follows the same shape. Comparing against the heap root is O(log k) with k = 10, so the total is O(n log 10) rather than O(n log n). CPython's `sorted` is extremely fast, so for small n the sort can win outright — the crossover is roughly when k is small relative to n, which is exactly the top-N case.
Both take a `key`, which is how you do this over records rather than scalars: `heapq.nlargest(10, rows, key=lambda r: r["amount"])`. Ties are broken by input order in both, and neither is stable in a way you should rely on — if ties matter, put the tiebreaker in the key tuple explicitly.
Two edges worth knowing. When k approaches n, `nlargest` degrades and the docs themselves suggest sorting instead. And if you need the top N *per group* — top 10 customers per region, the classic — the answer is a dict of heaps, one bounded heap per key, which keeps the memory proportional to the number of groups rather than to the data. That is the version that actually comes up in pipeline work.
The formulations
heapq.nlargestship
top = heapq.nlargest(10, rows, key=lambda r: r["amount"])
O(k) memory, consumes an iterator, and never materialises the 50 million rows.
A heap per groupship
for row in rows:
h = heaps[row["region"]]
heapq.heappush(h, (row["amount"], row["id"]))
if len(h) > 10:
heapq.heappop(h)
Top-N per group in one streaming pass; memory is groups x 10, not the dataset.
sorted(...)[:10]avoid
top = sorted(rows, key=lambda r: r["amount"], reverse=True)[:10]
Materialises everything to throw almost all of it away; fine for thousands, not for millions.
See it run on CPython 3.12
Same three values by either route. The third line shows the `key` form over records.
The answer most people give
"Sorting is O(n log n), which is fast enough." Time was never the binding constraint — memory was. Sorting requires the whole dataset resident, and on a stream you cannot sort at all.
They’ll ask next
Now it is the top 10 per region, with 5,000 regions. What changes?
Sort rows by region ascending and amount descending. How, and what does "stable" buy you when the sort key is not obvious?
Why they ask this
Mixed-direction sorts come up constantly and the tuple trick only works for numbers. Knowing that Python's sort is stable — and what that enables — is the general answer.
Say this
Either one sort with a key tuple that negates the descending numeric field, `key=lambda r: (r["region"], -r["amount"])`, or two stable sorts applied least-significant first. The second works for any type, including strings and dates you cannot negate.
The reasoning
A key tuple sorts lexicographically, so `(region, -amount)` gives region ascending and amount descending in one pass. It is the cleanest form, and it only works when the descending field is numeric — you cannot negate a string or a date, and `reverse=True` applies to the whole comparison rather than to one component.
Stability is what covers the rest. Python's sort guarantees that records comparing equal keep their previous relative order, which means you can decompose a multi-key sort into a sequence of single-key sorts applied from least significant to most significant. Sort by amount descending, then sort by region ascending, and the second sort preserves the amount ordering inside each region. The snippet shows both routes producing the identical result.
Two performance notes. `operator.itemgetter("region")` is meaningfully faster than the equivalent lambda because it avoids a Python-level call per comparison, and `itemgetter` takes multiple fields — `itemgetter("region", "amount")` builds the tuple in C. And each pass is a full O(n log n) sort, so the two-pass version costs roughly twice the single-key one; use the key tuple when you can and the decomposition when you cannot.
The related trap worth mentioning is sorting heterogeneous data. `sorted([1, "a"])` raises `TypeError` in Python 3, which is a feature — but it means a column with mixed types from a messy source blows up at sort time rather than at parse time. Normalise the type in the key function (`str(r["id"])` or an explicit parse) if the source cannot be trusted, and be aware that changes the ordering.
See it run on CPython 3.12
Rows a, c and d share a region. The first line shows stability preserving their input order.
The answer most people give
"Use `sorted(rows, key=..., reverse=True)` for the descending part." `reverse=True` reverses the entire comparison, so it flips the region ordering too. It is a whole-sort flag, not a per-field one.
They’ll ask next
The descending field is a timestamp string. You cannot negate it — now what?
When do you reach for pandas or polars instead of plain Python, and when is reaching for them the wrong move?
Why they ask this
Both over-use and under-use are common, and both are expensive. The answer shows whether you pick tools by shape of problem or by habit.
Say this
Use a dataframe when the work is columnar and vectorisable — aggregations, joins, window calculations over a table that fits comfortably in memory. Use plain Python when the data is row-shaped, nested, or streaming, and the work is per-row logic that would end up in `.apply` anyway.
The reasoning
Dataframes win when the operation is expressible over whole columns. A `groupby().sum()` runs in C over contiguous arrays and will beat a Python loop by one to two orders of magnitude. They also give you a huge amount of correct, tested behaviour for free — joins, pivots, resampling, null handling — that is genuinely tedious to write by hand.
They lose in three situations. Memory: pandas typically wants several times the file size in RAM, and object-dtype columns (which is what strings become) are pointers to individual Python objects, so a "small" 2 GB CSV of text can become 10 GB in memory. Shape: deeply nested JSON has to be flattened before a dataframe helps, and if the per-row logic is genuinely irregular you end up in `.apply`, which is a Python loop with dataframe overhead on top — the worst of both. Streaming: pandas is fundamentally in-memory, so a source larger than RAM needs chunking, which is exactly the plain-Python generator structure with extra steps.
Polars changes some of those numbers rather than the principle. It is columnar and multithreaded with the GIL released, uses Arrow memory so strings are not Python objects, and its lazy API can push filters and projections down and stream larger-than-memory data. Where pandas would need chunking, polars often just runs. DuckDB fills the same niche from the SQL side and reads Parquet and CSV out-of-core.
The framing to offer: choose by the shape of the operation, not by the size of the dependency. Columnar work over a table — dataframe. Row-at-a-time work over a stream, or arbitrary nested structures — plain Python generators. And if the whole job is a `SELECT ... GROUP BY` over files, the honest answer is often that neither Python nor pandas should be doing it.
A Python loop with dataframe overhead added — slower than the plain loop it replaced.
The answer most people give
"pandas is always faster because it is written in C." Only for vectorised operations. `df.apply(..., axis=1)` is a Python-level loop over rows, and it is routinely slower than the same loop over a list of dicts.
They’ll ask next
The file is 2 GB of mostly text and the box has 8 GB. Would pandas load it? What would you check before finding out?
Merge 500 CSV files from a directory into one pandas DataFrame. What is the mistake almost everyone makes on the first attempt?
Why they ask this
It looks like a one-liner and it hides a quadratic: the obvious loop is O(n²) in the data, and at 500 files that is the difference between seconds and minutes.
Say this
Read each file into a list and call pd.concat once at the end. Concatenating inside the loop copies the whole accumulated frame on every iteration, which is quadratic.
The reasoning
**The quadratic.** `df = pd.concat([df, chunk])` inside a loop allocates a brand new frame each time and copies everything accumulated so far. By file 500 you have copied the first file's rows 500 times. Total work is proportional to the square of the data. The same trap applies to the older `df.append`, which is why it was deprecated and removed.
**The fix is to defer.** Collect the frames in a list and concatenate once — a single allocation sized correctly, and one pass. Add `ignore_index=True` unless the source indices mean something, because otherwise you get 500 rows numbered 0 and any later `.loc` lookup is ambiguous.
**Add provenance while you are reading.** Stamping the source filename onto each chunk costs nothing and is the difference between "row 84,102 is wrong" and "row 84,102 came from `orders_2026_03_14.csv`". On a directory load it is the single most useful column you can add.
**The failure modes that actually bite in production.** Columns in a different order across files — `concat` aligns by name, which is what you want, but a typo'd header becomes a new mostly-null column rather than an error. Inconsistent dtypes — one file where an id column happens to contain a non-numeric value makes that column `object` and silently poisons the join downstream. And an empty file produces an empty frame with no columns, which then contributes nothing and is easy to miss. Passing an explicit `dtype` and checking the final column set against the expected one catches all three.
**When 500 files becomes 50,000**, pandas stops being the right tool — the whole result has to fit in memory. That is the point to move to Parquet with a columnar reader, or to a query engine that reads the directory as one table.
The formulations
Collect, then concat onceship
frames = []
for p in sorted(Path(d).glob('*.csv')):
f = pd.read_csv(p, dtype=SCHEMA)
f['source_file'] = p.name
frames.append(f)
df = pd.concat(frames, ignore_index=True)
One allocation, one pass, and provenance for free.
Generator into concatship
df = pd.concat((pd.read_csv(p) for p in paths), ignore_index=True)
Same cost, and it does not hold a named list. Fine either way.
Concat inside the loopavoid
for p in paths:
df = pd.concat([df, pd.read_csv(p)])
Copies the accumulated frame every iteration. Quadratic.
df.append in a loopavoid
df = df.append(pd.read_csv(p))
Same quadratic, and removed from pandas 2.0 entirely.
The answer most people give
"Loop and concat each file onto the result." It is the natural way to write it and it is the quadratic. The rule generalises beyond pandas: building an immutable container by repeated concatenation is always O(n²) — accumulate in a list and materialise once.
They’ll ask next
One file has an extra column and another has the columns in a different order. What does concat do?
You are summing millions of monetary amounts and converting between currencies. Why not float, and what do you use instead?
Why they ask this
Every fintech loop asks it, and the answer has two valid halves — integers or Decimal — that fail in different ways when mixed.
Say this
Binary floats cannot represent most decimal fractions, so errors accumulate over millions of additions. Use integer minor units, or Decimal with an explicit context — and never mix the two.
The reasoning
**Why float fails.** A `float` is binary, and 0.1 has no exact binary representation any more than 1/3 has an exact decimal one. `0.1 + 0.2 == 0.30000000000000004`. One such error is invisible; summed over ten million transactions it is a reconciliation break that nobody can trace, because every individual row looks right.
**Option one: integer minor units.** Store 1234 for £12.34 and do all arithmetic in integers, formatting only at the boundary. Addition and subtraction are exact, it is fast, and it maps directly to how payment systems and ledgers actually represent money. The cost is that you must remember the scale — and the scale is not always 2, since JPY has no minor unit and some currencies use 3 decimal places, so a hardcoded ÷100 is a real bug.
**Option two: `Decimal`.** Exact decimal arithmetic with a configurable precision and rounding mode. It expresses money naturally, it handles division and percentages without you tracking scale by hand, and it is slower and heavier than integers — which matters for tens of millions of rows in Python and not at all for thousands.
**Division and conversion are where both need care.** Currency conversion introduces a fraction: 100 GBP at 1.2734 is 127.34 exactly, and at 1.27345 it is not. Decide the rounding — `ROUND_HALF_EVEN` is the usual financial default because it does not bias totals upward the way `ROUND_HALF_UP` does — and quantise explicitly at the point of conversion rather than letting the precision drift. Allocating a total across lines has the same problem: split 100 three ways and you must decide who gets the extra penny, and *not* deciding means the parts do not sum to the whole.
**Never mix.** `Decimal('0.1') + 0.1` raises, which is helpful, but `Decimal(0.1)` silently constructs the *float* error into a Decimal — you must build from a string. And a database `NUMERIC` read through a driver that hands you floats has already lost the precision before your code sees it, so the type has to be right end to end.
The formulations
Integer minor unitsship
amount_minor: int = 1234 # £12.34
total = sum(amounts_minor) # exact, fast
What ledgers do. Store the currency so the scale is not assumed.
Decimal from stringsship
from decimal import Decimal, ROUND_HALF_EVEN
d = Decimal('12.34')
converted = (d * rate).quantize(Decimal('0.01'), ROUND_HALF_EVEN)
Exact and expressive. Quantise at the conversion, not later.
Constructs the float error into the Decimal. Always build from a string.
floatavoid
total = sum(0.1 for _ in range(10_000_000))
Error accumulates. Every individual row still looks correct.
The answer most people give
"Round to 2 decimal places at the end." Rounding a total that has already drifted only hides the drift. The error entered on every addition, so the sum was wrong before you rounded it — and rounding cannot recover what the representation never held.
They’ll ask next
Split 100.00 across three line items. What do the three amounts sum to?
Compare two ledgers in memory and return what is missing on each side and what disagrees. How do you structure it, and what does it cost?
Why they ask this
It is the Python twin of the SQL reconciliation question, and the structure — three outputs, not one — is what candidates get wrong.
Say this
Index both sides into dicts by the business key, then take the three set operations on the key sets: only-in-A, only-in-B, and in-both-where-values-differ. Linear time, and the memory is one dict.
The reasoning
**Three outputs, not one.** "Do these match" is not a boolean — it is *missing on the left*, *missing on the right*, and *present on both but different*. Returning a single list of differences collapses three distinct operational responses into one, and the person on the other end has to re-derive which is which.
**Index, then use set operations on the keys.** Build `{key: record}` for each side, then `a.keys() - b.keys()`, `b.keys() - a.keys()`, and for the intersection compare the values. Each dict build is O(n), each set operation is O(n), and the whole thing is linear with one dict per side in memory. The nested-loop version — for each row in A scan B — is O(n·m) and is the version people write first.
**Compare the fields you mean, not the records.** Two records that differ only in an ingestion timestamp are not a mismatch. Extract a comparison tuple of the fields that must agree and compare those, so the diff reports business differences rather than metadata noise. And use exact types — comparing a `Decimal` to a `float` will report a difference that is not one.
**The key is the hard part, same as always.** If the two systems identify rows differently, the mapping between their keys is the actual work and the diff is trivial afterwards. And if either side can have duplicate keys, a dict silently keeps the last one — so either assert uniqueness while building and fail loudly, or group into lists and compare multisets. Silently dropping a duplicate makes a reconciliation report that a duplicate-caused imbalance does not exist.
**When it does not fit in memory**, this becomes the sort-merge join: sort both sides by key and walk them together in one pass with constant memory. Same three outputs, and it is the reason external merge sort exists — at which point the honest answer is usually to do it in SQL instead.
The formulations
Index and take set differencesship
a = {r.key: r for r in left}
b = {r.key: r for r in right}
only_a = a.keys() - b.keys()
only_b = b.keys() - a.keys()
diff = {k for k in a.keys() & b.keys() if cmp(a[k]) != cmp(b[k])}
Linear, three clear outputs, one dict per side.
Assert key uniqueness while buildingship
if r.key in a: raise ValueError(f'duplicate key {r.key}')
A dict silently keeps the last duplicate and hides the imbalance.
Sort-merge for large inputsworks
walk both sorted iterators together, constant memory
When it does not fit. Also the point to consider doing it in SQL.
Nested loopavoid
for x in left:
for y in right:
if x.key == y.key: ...
O(n·m). At 100k rows a side that is ten billion comparisons.
The answer most people give
"Compare the two lists with == and report whether they match." Order-dependent, tells you nothing about *what* differs, and fails on lists that hold the same records in a different sequence — which two independent systems always will.
They’ll ask next
The left side has two rows with the same key. What does your dict do, and what does the report say?
You have to process a 200 GB file on a machine with 16 GB of RAM. How do you approach it?
Why they ask this
It is the standard scale question, and it separates people who reach for a bigger machine from people who change the shape of the computation.
Say this
Stream it: read line by line or in chunks, keep only bounded state, and write output incrementally. If the computation genuinely needs all the data at once, partition it so each part fits, or move it to an engine built for that.
The reasoning
**First, decide what state the computation needs.** A filter or a projection needs none — it is a generator pipeline and the file size is irrelevant. A sum or a count per key needs state proportional to the number of keys, not to the rows, which is usually small. Only a sort, a join against something equally large, or a distinct count genuinely needs everything.
**Stream by default.** Iterate the file object rather than calling `read()`, and chain generators so nothing materialises: parse, filter, transform, write. Memory then depends on your batch size and on the state you chose to keep, and both are numbers you can state rather than hope about.
**Bound the state you cannot avoid.** A distinct count over a huge key space is the classic — an exact answer needs every key in memory, and the honest options are a sketch such as HyperLogLog, a two-pass approach, or pushing it to a database. Say which trade you are making rather than pretending the problem is not there.
**Then the shape questions.** Is the file splittable — line-delimited rather than one giant JSON document? Is it compressed with a non-splittable codec, which forces a single reader? Could the source produce Parquet instead, so you read three columns instead of eighty? Those change the answer more than any code you write.
**And know when to stop.** Pure Python streams fine and is roughly an order of magnitude slower than a columnar engine on the same work. If the job runs nightly and takes forty minutes, leave it. If it is the critical path, the answer is DuckDB, Polars or Spark, and saying so is a stronger answer than optimising a loop.
The formulations
Stream and aggregateship
totals = Counter()
with open(path) as fh:
for line in fh:
totals[key_of(line)] += 1
Memory is the key count, not the row count.
Chunked writesship
for batch in chunked(rows, 10_000):
sink.write(batch)
Bounded memory on the output side too.
Read it allavoid
rows = open(path).readlines() # 200 GB
The one line the whole question is about.
Sort in memoryavoid
rows.sort(key=...) # needs everything at once
Use an external sort, or push it to an engine.
The answer most people give
"Use pandas with chunksize." It is a real answer for some shapes and it is not a plan — it does not say what state you keep between chunks, which is the entire difficulty.
They’ll ask next
Which operations genuinely cannot be done in one streaming pass, and what do you do about each?
A team is choosing between CSV, JSON, NDJSON, Parquet and Avro for a new dataset. How do you decide?
Why they ask this
It is a design question with a real answer, and the answer depends on the read pattern rather than on which format is newest.
Say this
Decide by how it is read and who has to read it: columnar (Parquet) for analytics over a few columns of many rows, NDJSON for streaming appends and nested payloads, Avro when schema evolution is a first-class requirement, CSV only when a human or a legacy tool demands it.
The reasoning
**The first question is the read pattern.** Analytics reads a few columns of very many rows, and that is what columnar formats are for — Parquet reads only the columns you asked for and skips row groups whose statistics rule them out. A row format has to read every byte to find three fields.
**The second is the shape.** Deeply nested payloads survive JSON and NDJSON honestly; flattening them into CSV loses structure, and CSV has no types at all, so every consumer re-parses every value and they disagree about how. NDJSON is the streaming form: one object per line, appendable, and readable one record at a time.
**The third is schema evolution.** Avro carries its schema and has explicit rules for adding and removing fields, which is why it turns up in event pipelines where producers and consumers deploy separately. Parquet also carries a schema; the difference is that Avro is designed for the message and Parquet for the table.
**And the fourth is who else touches it.** CSV is the format everybody can open and nobody can trust: no types, ambiguous quoting, no null-versus-empty distinction. It is the right answer for a hand-off to a person and the wrong one for a pipeline stage, and "we have always used CSV" is a reason to ask what it costs.
**Say the trade, not the favourite.** Parquet is worse than NDJSON for tiny frequent appends, because small files are the thing that kills a lakehouse. Compression matters too: gzip is not splittable, so one 10 GB gzip file is one reader whatever your cluster size.
The formulations
Parquet for the tableship
# read 3 of 80 columns, skip row groups by statistics
The default for anything queried analytically.
NDJSON for the streamship
{"id":1,"payload":{...}}
{"id":2,"payload":{...}}
Appendable, nested-safe, readable one line at a time.
CSV between pipeline stagesavoid
id,amount,note
1,"12,50",ok
No types, and quoting rules everybody re-implements badly.
One giant gzipavoid
events-2026-09.json.gz # 10 GB, one reader
Not splittable, so parallelism is capped at one.
The answer most people give
"Parquet, it is faster." Faster at one thing — reading a few columns of many rows. For small frequent appends it is worse, and for a hand-off to a human it is unusable.
They’ll ask next
Why is a single large gzip file a problem for a distributed reader, and what would you do instead?
When do you convert a record from a plain dict to a dataclass, and when is a dict the right answer?
Why they ask this
Pipeline code is full of both, and the choice decides whether a field-name typo is caught at the boundary or discovered in a report.
Say this
Dicts at the edge, where the shape is unknown and unvalidated; typed records inside, once the keys are a contract other code relies on. Convert once, at the validation boundary, and stop using dicts after it.
The reasoning
**Why a dict at the edge.** JSON and CSV hand you a dict, the keys vary, and you do not yet know the shape is right. Forcing it into a typed record before validation gives you a well-typed object full of unchecked values, which reads as safer than it is.
**Why a record inside.** After validation the keys are a contract. `record.amuont` fails immediately with the name in the message; `row.get('amuont')` returns `None` and surfaces three functions later as a total that is quietly low. That difference is worth more than any performance argument between the two.
**What you get for free.** A dataclass gives you a `__repr__` that names its fields — `Order(order_id=1042, sku='A1')` in a log line beats a dict dumped in insertion order — and an `__eq__` that makes tests read as comparisons rather than as dict spelunking. Frozen, it can be a key.
**When a dict stays right.** Genuinely variable keys, a passthrough that never inspects the payload, or a shape that changes per tenant. Wrapping those in a class buys nothing and adds a conversion nobody reads.
**And the practical marker.** Count the defensive `.get()` calls in the code downstream of the boundary. Every one of them is a place the shape was uncertain, and converting once turns all of them into attribute access that fails loudly at the one place the record was built.
The formulations
Convert at the boundaryship
raw = json.loads(line) # dict
order = to_order(raw) # validated record
One conversion, and the failure has a place to happen.
NamedTuple for a keyworks
class Key(NamedTuple):
order_id: int
sku: str
Immutable, hashable, unpackable, and very cheap.
Dicts all the way downavoid
def total(rows):
return sum(r.get('amount', 0) for r in rows)
A typo is a zero, and the report is quietly low.
Typed before validatedavoid
Order(**raw) # amount='12,5' accepted
Annotations document; they do not check.
The answer most people give
"Dataclasses are slower, so use dicts." The construction cost is real and irrelevant next to a field-name typo that surfaces in a finance report a week later.
They’ll ask next
What would make you pick a NamedTuple over a frozen dataclass?