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
30
The flat form, in the words interviewers actually use. Same ground as the questions above, asked as recall rather than as a situation.
01 / 50
Data structures & complexity
You have one million rows in Python. Each row is a pair: `(region, amount)`. You need all the amounts for each region, grouped together. Which ways can you write this, and which one do you pick for this size? Say what each way costs.
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?
You have a list of 10 million events. Each event has a type, such as `login` or `purchase`. You need to count how many events there are of each type. Which Python tool do you use to count, and why? Does your answer change if you also need only the 10 most common types?
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 one million ids to check. For each id, you must find out if it is in a reference collection of 100,000 ids. What type do you build the reference collection as, a list or a set? Say why, and say what the values must be for that type to work.
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 code uses a `defaultdict(list)` to collect events by region. Later, you count the keys to report how many regions you have seen. The count is too high, and some keys hold an empty list. What added those keys, and how do you read a key without this side effect?
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?
You have a 40 GB CSV file and a machine with 8 GB of memory. You need the sum of one column, grouped by another column. How do you write the code so it works on this machine? Say what stays in memory at any moment.
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?
In Python you usually keep data as a generator, so it streams one row at a time. Sometimes you should call `list()` on it instead and hold all the rows in memory. When is that the right choice? Give the cases, and say what you must know about the data first.
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?
You receive a stream of 50 million ids and must remove the duplicates. The first idea is to add every id to a `set`. How much memory does that set need? At what point does a set stop being the right tool, and what do you use instead?
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 read rows from a generator, one row at a time. The sink accepts at most 1,000 rows per request. How do you group the rows into batches of 1,000 without loading the whole stream into memory? Name the Python tools you would use.
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?
One row of data moves through five stages of your pipeline. You can carry it as a tuple, a dict, a `NamedTuple` or a `dataclass`. Which do you choose at each stage, and what decides the choice?
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 hold 50 million small Python objects in memory and they do not fit. Someone suggests `@dataclass(slots=True)`. What does `slots=True` change inside each object, and how much memory does it save? What do you lose by using it?
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 a field `rows: int`. A caller creates it with the string `"12"` instead of a number. What happens at that moment? Where in a pipeline should the check for this live, and how do you write it?
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 deduplicate rows on two fields together: `customer_id` and `event_day`. You need one key for each row. Do you build the key as a formatted string like `'42|2026-03-01'`, as a tuple, or as something else? Say what can go wrong with each option.
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
You must fetch 500 pages from a paginated API. Each request takes about 200 ms, so one after another takes 100 seconds. Which Python tool do you use to run the requests at the same time? How many do you run at once, and what sets that limit?
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
You parse and clean 40 GB of JSON in Python. The work is CPU-bound, so you use `ProcessPoolExecutor` with one task per row. It runs slower than the plain serial version. Why can that happen, and how do you split the work so it is faster?
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
You run 500 tasks with a `ThreadPoolExecutor`. You can collect the results with `pool.map` or with `as_completed`. In what order does each one give you the results? When do you pick each one?
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
You submit a task to a `ProcessPoolExecutor` and pass a lambda as the function. Python fails with a pickling error. Why does a lambda fail here, and what kind of function 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?
You have 10 million orders and a customer table with 100,000 rows. Each order has a `customer_id`. In plain Python, you must add the customer name to each order. How do you structure this join so it finishes in minutes, not days? Say how many operations your way needs.
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 have 50 million orders and need only the 10 largest by amount. The simple way is to sort all the orders and take the first 10. Is sorting the right choice here? Say what you would do instead and what it costs in time and memory.
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?
You have rows with a `region` and an `amount`. You need them sorted by region from A to Z, and inside each region by amount from high to low. How do you write that sort in Python? Python's sort is stable; say what stable means and how it helps here.
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?
You get a new data task in Python, on a 2 GB file. You can write it in plain Python, or load the data into pandas or polars. How do you decide which to use? Give one case where a dataframe is right and one case where it is the wrong tool.
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?
You have two ledgers loaded in memory: one from your system and one from the bank. Each row has a transaction id and an amount. You must return three lists: ids only in yours, ids only in the bank's, and ids in both where the amounts differ. How do you structure the code, and what does it cost in time and memory?
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?
A record moves through your pipeline as a plain dict. When do you convert it to a dataclass? When is the dict the right choice?
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?
In pandas, what is the difference between `groupby().agg()`, `groupby().transform()` and `groupby().apply()`? Which one gives each row its share of its group total?
Why they ask this
It is the most common pandas question for data engineers. The answer shows whether you understand the shape of the result, not only the syntax.
Say this
agg returns one row per group. transform returns a result with the same length and index as the input, so it can be put back next to the original rows. apply runs any function per group and is flexible but slower. The share of a group total needs transform.
The reasoning
**agg: one row per group.** `df.groupby("user")["amt"].sum()` returns one value per user: for the amounts 10, 20, 5, 5, 40 with users a, a, b, b, b it gives `a 30` and `b 50`. Use it when the output is a summary table.
**transform: same shape as the input.** `df.groupby("user")["amt"].transform("sum")` returns `[30, 30, 50, 50, 50]`: the group total repeated on every row, aligned to the original index. That is exactly what you need to add a column or compute a share: `df["amt"] / df.groupby("user")["amt"].transform("sum")` gives `0.333, 0.667, 0.1, 0.1, 0.8`.
**apply: any function, any shape.** `apply` passes each group to a Python function and stitches the results back. It can do things agg and transform cannot, such as returning the top two rows of each group, but it runs your Python code once per group, and the output shape depends on what the function returns. Prefer the built-in aggregations first.
**The mistake to avoid.** Computing the totals with `agg`, then merging them back by key, works but takes three steps and risks a many-to-many merge. `transform` does it in one step and cannot change the number of rows. (Checked on pandas 3.0.)
A Python call per group for something the engine already does.
The answer most people give
"They are all the same, so I just use apply because it can do everything." apply is the slowest of the three, and the shape of its output depends on your function. Use agg for summaries and transform for per-row values.
They’ll ask next
How would you keep only the latest row for each user?
You merge an orders table with a customers table and the row count goes up. What happened, how do you catch it, and how do you stop it?
Why they ask this
Join fan-out is the most expensive silent bug in analytics. A candidate who checks row counts and uses validate is thinking like an engineer who has been burned.
Say this
The key was not unique on the side you thought it was, so each order matched several customer rows. Pass validate="m:1" so pandas raises an error if the right side has duplicate keys, and compare row counts before and after every merge.
The reasoning
**What happened.** A merge produces one output row for every matching pair. If `customers` has two rows for `c1`, every order from `c1` is repeated twice. With 2 orders and 2 matching customer rows, the merge of this example returns **4** rows from 2 orders, and every total built on top of it is now double counted.
**How to catch it.** Pass `validate="m:1"` (many orders to one customer). If the right side has duplicate keys, pandas raises `MergeError: Merge keys are not unique in right dataset; not a many-to-one merge`. The other choices are `"1:1"`, `"1:m"` and `"m:m"`. Also assert that `len(result) == len(left)` after a left join.
**How to see the unmatched rows.** `indicator=True` adds a `_merge` column with `both`, `left_only` or `right_only` for each row, so you can count the orders that found no customer instead of letting them turn into nulls.
**How to stop it.** Fix the dimension: deduplicate it on the key with a clear rule (latest row, active row) **before** the merge, or add the missing second key to the join. Do not fix it by dropping duplicates after the merge, because by then you cannot tell which row was the right one. (Checked on pandas 3.0.)
Fails loudly when the dimension has duplicate keys.
Merge and hopeavoid
out = orders.merge(customers, on="cust")
Silent row multiplication, and an inner join also drops unmatched orders.
drop_duplicates after the mergeavoid
out = orders.merge(customers, on="cust").drop_duplicates("order_id")
Keeps an arbitrary customer row. The wrong tier or region can survive.
The answer most people give
"Just call drop_duplicates on the result." That hides the symptom and keeps an arbitrary matching row. The cause is a non-unique key, and it should be fixed or rejected before the join.
They’ll ask next
How would you pick the right customer row when the customers table has history?
In pandas, what is the difference between `.loc` and `.iloc`? How is a Series different from a DataFrame? And why does `df[df.a > 1]["b"] = 9` not change the data?
Why they ask this
Three small questions in one. They test whether you know how pandas selects data, and whether you have met the chained-assignment bug.
Say this
.loc selects by label and includes the end of a slice. .iloc selects by position and excludes the end. A DataFrame is a table and a Series is one labelled column. The chained assignment sets a value on a temporary copy, so use df.loc[mask, "b"] = 9.
The reasoning
**loc versus iloc.** With the index `[5, 6, 7, 8]`, `df.loc[6:7]` returns the rows labelled 6 **and** 7, because label slices include the end. `df.iloc[1:3]` returns the rows at positions 1 and 2, because position slices stop before the end. They give the same rows here only by coincidence. If the index is not a simple 0, 1, 2 count, mixing them up returns the wrong rows without an error.
**Series versus DataFrame.** A `DataFrame` is a two-dimensional table with a row index and column labels. A `Series` is one column (or one row) with an index. `df["v"]` is a Series, `df[["v"]]` is a one-column DataFrame, and `df.loc[5]` is a Series holding one row.
**The chained assignment.** `df[df.a > 1]["b"] = 9` first creates a new object with the filtered rows, then sets a value on that new object. The original `df` is untouched. In pandas 3, with copy-on-write, that is the defined behaviour and pandas emits a `ChainedAssignmentError` warning. In older versions the result depended on whether pandas had made a copy, which is why it was called SettingWithCopy.
**The correct form.** `df.loc[df.a > 1, "b"] = 9` selects rows and column in one step and writes to the original. We checked: on `a = [1, 2, 3]` and `b = [0, 0, 0]`, the chained form leaves `b` as `[0, 0, 0]` and the `.loc` form gives `[0, 9, 9]`.
The formulations
One .loc callship
df.loc[df["a"] > 1, "b"] = 9
Row mask and column label together, and it writes to df.
Chained indexingavoid
df[df["a"] > 1]["b"] = 9
Writes to a temporary copy and leaves df unchanged in pandas 3.
iloc with a label by mistakeavoid
df.iloc[6] # position 6, not the row labelled 6
On a non-default index, this selects a different row, or raises IndexError.
The answer most people give
"loc and iloc are the same except for the name." They differ in what they select by (label or position) and in whether a slice includes its end, so they return different rows on any index that is not 0, 1, 2.
They’ll ask next
What does copy-on-write change about how you write pandas code?
You need to add a column computed from two other columns on a few million rows. Why is df.apply(axis=1) the wrong first choice, and what do you use instead?
Why they ask this
It separates people who write pandas like Python from people who write it like a column engine, and it comes up the moment a job gets slow.
Say this
df.apply(axis=1) calls a Python function once for every row, so it runs at Python speed. Vectorised operations such as df["a"] * df["b"] run over whole columns in compiled code. Use column arithmetic, np.where, and string or datetime accessors, and keep apply for logic you cannot express that way.
The reasoning
**Why apply is slow.** With `axis=1`, pandas builds each row as a Series and calls your function in the Python interpreter, once per row. A few million rows means a few million Python calls. The work is the same as a `for` loop with extra overhead.
**What vectorised means.** An operation such as `df["price"] * df["qty"]` is handed to compiled code that loops over the arrays in one go. The result is identical to the row-by-row version, which we checked on a small frame: `apply(lambda v: v * 2)` and `df.x * 2` produced the same values.
**The vectorised toolbox.** Arithmetic and comparisons on columns, `np.where(cond, a, b)` or `Series.where` for if-else, `.str` and `.dt` accessors for text and dates, `pd.cut` for bins, `map` with a dict for lookups, and `merge` for joins. Most row-wise logic can be written with these.
**When apply is acceptable.** The logic really is row-wise and cannot be expressed on columns, or the frame is small. Then say it out loud: "I would use apply here, and if it were hot I would rewrite it as column operations, or move the step into SQL or Spark". Measure before you rewrite.
A Python call per row, for something expressible on columns.
iterrows loopavoid
for i, r in df.iterrows():
df.loc[i, "fee"] = ...
Slowest of all, and writes one cell at a time.
The answer most people give
"apply is vectorised because it is a pandas method." It is a pandas method, but with axis=1 it calls your Python function for each row.
They’ll ask next
How would you convert a string column to dates for a few million rows without a loop?
31 / 50
Evergreenpandas / polars
How does pandas represent a missing value? What goes wrong when you compare `NaN` to itself, count rows, or sum a column that is all missing?
Why they ask this
Null handling is where most data quality bugs live. The interviewer wants to hear that missing is a special value with special rules.
Say this
A float column stores a missing value as NaN, which is not equal to anything, not even itself. Nullable types such as Int64 use pd.NA instead, which keeps integers as integers. Use isna() to test, and know that sums and means skip missing values by default.
The reasoning
**NaN.** In a float column, `None` is turned into `NaN`: `pd.Series([1, None, 3])` has dtype `float64`, and the 1 and 3 become `1.0` and `3.0`. `NaN == NaN` is `False`, so you test with `isna()`, never with `==`.
**pd.NA and nullable types.** `pd.Series([1, None, 3], dtype="Int64")` keeps integers and shows the gap as `<NA>`. `pd.NA == 1` returns `<NA>`, not `False`, and using it in an `if` raises `TypeError`, because the answer to "is it equal" is unknown. This is the same three-valued logic as SQL NULL.
**Aggregations skip missing values.** `mean()` of `[1, NaN, 3]` is `2.0`, computed over two values, and `count()` does not count the missing one. The sum of a column that is entirely missing is `0.0`, not `NaN`, which can look like a real zero in a report. Pass `min_count=1` to get `NaN` when nothing is present.
**What to do with them.** Decide the rule first: drop the rows (`dropna`), fill with a default (`fillna`), or keep the gap and carry it. Count the missing values before and after a cleaning step, and write the rule down, because "fill with 0" and "ignore" give different totals. (Checked on pandas 3.0, where text columns default to a string dtype whose missing values also test true with `isna()`.)
The formulations
Test with isnaship
bad = df[df["amount"].isna()]
The only reliable test. It works for NaN, None and NA.
Compare with ==avoid
bad = df[df["amount"] == np.nan] # always empty
NaN is not equal to anything, including itself.
Nullable integership
df["qty"] = df["qty"].astype("Int64")
Keeps whole numbers and allows missing values.
The answer most people give
"Missing values are just None, so == None finds them." In a float column the None has become NaN, and NaN == None is False. Use isna().
They’ll ask next
Why can the sum of an all-missing column look like a real zero?
You have a string column of dates in pandas. Some values are invalid, and the time zone offsets are mixed. How do you convert the column to datetimes safely?
Why they ask this
Dates arrive as messy text from files and APIs. The question checks that you handle bad values and time zones on purpose, not by accident.
Say this
Use pd.to_datetime. By default it raises on a bad value. errors="coerce" turns bad values into NaT so you can count and quarantine them, and utc=True converts values with different offsets to one common time zone.
The reasoning
**The default is strict.** `pd.to_datetime(series)` raises a `ValueError` when it meets a value it cannot parse, such as `"2026-13-45"` or `"not a date"`. That is a good default in a pipeline, because the load stops before bad data lands.
**Coerce, then count.** `errors="coerce"` turns every unparseable value, and every missing value, into `NaT` (not a time). Do not stop there: count the `NaT` values that were **not** missing before, and write those rows to a reject file. Otherwise a format change in the source silently becomes a column of nulls.
**Time zones.** `"2026-01-05 10:00:00+02:00"` and `"2026-01-05 08:00:00+00:00"` are the same moment. With `utc=True`, pandas converts both to `08:00:00 UTC`, which we checked. Without it, mixed offsets cannot be stored in one tz-aware column. Store UTC, and convert for display only.
**Be explicit about the format.** Give a `format=` when you know it. It is faster, and it stops `01/02/2026` from being read as January 2nd in one file and February 1st in another.
A broken source turns into a column of NaT with no warning.
The answer most people give
"Use errors='coerce' so the pipeline never fails." The pipeline then never tells you the source changed. Coerce is the first half. The second half is counting what it turned into NaT.
They’ll ask next
How would you tell whether 03/04/2026 is March 4th or April 3rd?
An S3 prefix holds millions of keys. With boto3, how do you list every object under it? What is the mistake in calling `list_objects_v2` once?
Why they ask this
S3 listing is the first step of most file-based ingestion jobs, and the single-call version works in a test and silently truncates in production.
Say this
list_objects_v2 returns at most 1,000 keys per call. Use client.get_paginator("list_objects_v2").paginate(...), which follows the continuation token for you, and read each page with page.get("Contents", []) because an empty page has no Contents key.
The reasoning
**The limit.** One `list_objects_v2` call returns up to 1,000 objects. If there are more, the response has `IsTruncated: True` and a `NextContinuationToken`. A job that calls it once, reads the keys, and moves on processes the first 1,000 files and reports success.
**The paginator.** `paginator = client.get_paginator("list_objects_v2")` and `for page in paginator.paginate(Bucket=bucket, Prefix=prefix):` does the token handling for you. Yield from it instead of building a list if the prefix is huge, so memory stays flat.
**The empty-page trap.** When nothing matches, the page has no `Contents` key at all. `page["Contents"]` then raises `KeyError`, so write `page.get("Contents", [])`.
**Things to say next.** Keys are returned in UTF-8 byte order, so the list is sorted, which lets you resume from a `StartAfter` key. A prefix is not a folder, so `data/2026-01` also matches `data/2026-010`: end the prefix with a slash when you mean a folder. S3 gives strong read-after-write consistency for new objects and lists, so a listing right after a write sees it. For billions of keys, read an S3 Inventory report instead of listing.
The formulations
Paginatorship
paginator = s3.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=bucket, Prefix="events/2026-01/"):
for obj in page.get("Contents", []):
process(obj["Key"])
Follows the token, streams the keys, and tolerates an empty prefix.
One callavoid
resp = s3.list_objects_v2(Bucket=bucket, Prefix=prefix)
keys = [o["Key"] for o in resp["Contents"]]
Stops at 1,000 keys, and raises KeyError on an empty prefix.
The answer most people give
"Call list_objects_v2 and read Contents." It works on a test bucket with ten files and quietly processes only the first 1,000 on a real one.
You run a SQL query from Python, and part of the query comes from a variable. How do you do this safely? What can a query parameter not replace?
Why they ask this
It is a security and correctness question in one. Pasting values into SQL text is the classic injection bug, and data engineers write a lot of SQL in code.
Say this
Never build the query with an f-string or +. Write placeholders in the SQL and pass the values separately, so the database driver treats them as data and never as SQL. Parameters replace values only, not table or column names.
The reasoning
**The bug.** `f"... where name = '{name}'"` makes the value part of the SQL text. If the value is `x' OR '1'='1`, the condition becomes true for every row, and the query returns both users in the snippet. With `UPDATE` or `DELETE`, the same trick is far worse.
**The fix.** Put a placeholder in the SQL and pass a tuple of values: `db.execute("... where name = ?", (name,))`. The driver sends the statement and the values separately, so the value can never change the structure of the query. The same input returns no rows in the snippet. The placeholder style depends on the driver: `?` in sqlite3, `%s` in psycopg, `:name` in SQLAlchemy `text()`.
**What a parameter cannot do.** It stands for a **value**. It cannot be a table name, a column name, a sort direction or a keyword. For those, check the input against an allowlist you control, or use the library's identifier quoting helper, and never paste user text.
**Related habits.** Use `executemany` for batches of rows, keep credentials out of the connection string in code, and close the connection (or use a context manager) on every path. With many executions of one statement, a driver can also prepare it once.
The formulations
Placeholders and a tupleship
cur.execute("select * from orders where id = ?", (order_id,))
The value is data, so it cannot change the query.
Allowlist for an identifiership
if column not in {"created_at", "amount"}:
raise ValueError(column)
cur.execute(f"select * from orders order by {column}")
A column name cannot be a parameter, so check it against known values.
f-string with a valueavoid
cur.execute(f"select * from orders where id = '{order_id}'")
SQL injection, and a quote in the data breaks the query.
See it run on CPython 3.12
The same value, once pasted into the SQL text and once passed as a parameter.
The answer most people give
"I escape the quotes myself." Hand-written escaping misses cases that the driver handles. The safe way is to send the value separately as a parameter.
They’ll ask next
How would you let a caller choose which column to sort by?
A pipeline calls an API with `requests.get(url).json()`. What would you change to make this line safe in production? Name each change and the failure it prevents.
Why they ask this
Every API ingestion job starts with a call like this, and the one-liner has no timeout, no error check and no retry. The question probes the failure modes in order.
Say this
Add a timeout, because requests waits forever by default. Call raise_for_status so a 500 is an error and not an empty result. Reuse a Session for connection reuse and retry only transient errors (429 and 5xx) with backoff, respecting Retry-After, and never retry a write blindly.
The reasoning
**Timeout.** The requests documentation says it does not time out unless you set `timeout`. A hung server then hangs your job. Pass `timeout=(3.05, 30)`, a connect timeout and a read timeout in seconds, on every call.
**Check the status.** A 500 or 404 response still returns a response object, and `.json()` on an error page raises a confusing error or, worse, parses an error body as data. Call `response.raise_for_status()` first.
**Session and retry.** A `requests.Session` reuses connections. Mount an `HTTPAdapter` with a `urllib3` `Retry(total=5, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504])` to retry only errors that can heal. `Retry` honours the `Retry-After` header by default, and by default retries only idempotent methods, so a POST is not repeated.
**What retries must not do.** Retry a 400 or 404 (it will fail the same way), retry a write that may have committed without an idempotency key, or retry forever. Bound the attempts, add jitter when many workers run, and surface the final failure instead of returning an empty result. Keep the secret out of the URL and out of logged errors.
You load a million rows from Python into a database. The job can fail halfway, and it can be run twice. How do you load the rows fast? How do you make sure a failure or a re-run leaves no duplicates and no half-loaded table?
Why they ask this
It joins performance, transactions and idempotency in one question. Row-by-row commits are slow, and a load that is not atomic or repeatable is a data quality incident waiting to happen.
Say this
Send rows in batches with executemany (or the database bulk loader), commit once per batch inside a transaction so a failure rolls the batch back, and make the write idempotent with an upsert or a staging table and a merge, so a re-run replaces and does not duplicate.
The reasoning
**Batches, not rows.** One `INSERT` and one commit per row pays the network round trip and the commit cost a million times. Use `executemany` with batches of a few thousand rows, or the database's bulk path such as `COPY` in PostgreSQL. Commit per batch, not per row.
**A transaction makes a batch all or nothing.** In the snippet, the batch contains a duplicate key. The `with db:` block rolls back, so the table has **0** rows after the failure, not 2. Without the transaction, the rows before the bad one would stay, and the table would be half loaded.
**Idempotency makes a re-run safe.** The snippet then loads the same rows with `insert or replace`, and the table ends with exactly the three rows, the later duplicate winning. In other databases the same idea is `INSERT ... ON CONFLICT DO UPDATE`, or a load into a **staging table** followed by a single `MERGE`.
**Make progress visible.** Record which batches were committed, so a restart resumes instead of starting over, and check the final row count against the source. If part of the load cannot be rolled back, say so, and design for a clean re-run.
The formulations
Batches in a transaction, with an upsertship
for batch in batched(rows, 5000):
with conn:
conn.executemany(UPSERT_SQL, batch)
The target changes in one statement, whatever happens during the load.
Insert and commit each rowavoid
for row in rows:
cur.execute(INSERT_SQL, row)
conn.commit()
A round trip and a commit per row, and a crash leaves a partial table.
See it run on CPython 3.12
A failed batch inside one transaction leaves nothing behind, and an upsert makes a re-run safe.
The answer most people give
"Loop over the rows and insert each one, then commit at the end." It is slow because of the round trips, and a crash before the commit loses everything while a crash after a partial commit leaves duplicates on the re-run.
They’ll ask next
How would you resume after the job fails on batch 40 of 200?
You are given an iterator that yields one event dict at a time, with far more events than fit in memory. Count the events of each type per user. What do you not do, and what must you still ask about?
Why they ask this
Calling list() on the input is the quickest way to fail a data engineering Python round. It shows whether you think about memory before you write code.
Say this
Loop over the iterator once and update a dictionary of counters, so memory depends on the number of distinct users and types, not on the number of events. Never call list() on the input, and ask whether the counts themselves can fit in memory.
The reasoning
**One pass, small state.** `for event in stream:` reads one event, updates `per_user[user][type]` and forgets the event. Memory grows with the number of **distinct** users and event types, not with the number of events. The snippet builds no list of events.
**What not to do.** `events = list(stream)` loads every event first, which is the thing the iterator exists to avoid. Also avoid `len(stream)` and `stream[0]`, which iterators do not support, and do not loop twice. A generator can be consumed only once: the second loop in the snippet finds **0** events.
**Edge cases to state.** An empty stream returns an empty dict. An event missing the user or the type should be counted separately or sent to a reject list, not crash the loop. And if the stream is a file or a socket, close it on every path.
**The follow-up.** If the dictionary of counters is itself too large, the answer changes: partition the stream by a hash of the user and process each partition separately, spill to disk or a database, or use an approximate structure such as a count-min sketch when exact counts are not required.
See it run on CPython 3.12
Each event is seen once. No list of events is ever built, and a second loop would find nothing.
The answer most people give
"First convert the iterator to a list so I can loop over it." That reads every event into memory, and for a stream larger than memory it crashes before it counts anything.
They’ll ask next
What would you do if there were 500 million distinct users?
A job has to send rows to a sink in batches of n, from any iterable. How do you build the generator that yields the batches, and which edge cases will an interviewer test?
Why they ask this
Batching is in every load job, and the typical bug is a lost final partial batch. It also tests whether you can write a lazy function and avoid list().
Say this
Take an iterator and repeatedly slice n items off it with islice, yielding each non-empty list until nothing is left. On Python 3.12 and newer, itertools.batched does the same job. The cases to test are an empty input, an exact multiple of n and a short last batch.
The reasoning
**The generator.** Make an iterator with `iter(iterable)`, then loop: `batch = list(islice(iterator, size))`. If the batch is empty, stop. Otherwise `yield batch`. The snippet uses the walrus operator to do both in the `while` line. Nothing is read until the caller asks for the next batch.
**The built-in.** `itertools.batched(iterable, n)` has been available since Python 3.12 and yields **tuples**. Say that you would use it on a new version and write the generator when you must support older ones, and that you know the difference.
**Edge cases.** Seven rows in batches of three give `[1, 2, 3]`, `[4, 5, 6]` and `[7]`: the short last batch must be kept. An empty input gives no batches, not one empty batch. An exact multiple gives no extra empty batch at the end. And a `size` of 0 makes this generator quietly yield nothing, which hides a mistake, and a negative size makes `islice` raise, so check `size >= 1` first and raise a clear `ValueError`.
**Why it should be lazy.** Because the generator reads from an iterator, it works on a file or an API and holds only one batch in memory. Slicing a list with `rows[i:i+n]` works only on something that can be indexed, and copies each slice. Batch size for a real sink is also limited by its own rules, such as a maximum payload size.
You list a Python data pipeline on your resume. Describe it from the source to the destination. What are its steps, how is it scheduled, and what happens when it fails?
Why they ask this
It is the most common opening for a resume-based round. The interviewer is measuring whether you built it or watched it, and the rest of the round will follow the details you choose to mention.
Say this
Say it in a fixed order: the business purpose, the source and its volume, each step, the destination, how it is scheduled, and what happens when it fails. Use real numbers, and stop after about two minutes so the interviewer can pick a thread.
The reasoning
**Use a fixed order.** Purpose (who uses the output and why), source (what system, what format, how many rows or GB per run), steps (extract, validate, transform, load), destination (what table or file, and what the grain is), schedule (how it runs and how often), and failure handling (what you do when a step fails). The order keeps you calm and shows that you see the whole system.
**Prepare the numbers.** Rows or files per run, run time, how often it fails, and one measurable result such as "the report is ready by 7 a.m. instead of 11". If you do not know a number, say how you would find it. Do not invent one: the next question is often "how did you measure that?".
**Leave hooks on purpose.** Mention one decision and one problem, for example "I made the load idempotent because the source sometimes re-sent files". It tells the interviewer where to dig, and it lets you steer to a topic you know well.
**A fill-in template.** "We needed ___ for ___. The source is ___ (___ rows per day). A Python job does ___, ___ and ___, and writes to ___. It runs ___ via ___. When it fails, ___. One thing I owned was ___."
The answer most people give
"I used Python, pandas, Airflow and S3." That lists tools and says nothing about what the pipeline did, how big it was or what you decided. Tools are the last thing the interviewer asks about.
They’ll ask next
Which step of that pipeline was the most fragile, and why?
You run your pipeline twice for the same day. How do you know that nothing is double-counted? Say what makes the run safe to repeat, and how you would prove it.
Why they ask this
Retries and backfills are normal, so a pipeline that is not safe to rerun will eventually corrupt data. This is the question that separates a script from a pipeline.
Say this
Name the key that identifies a record, write with an upsert, a partition overwrite or a staging merge so the same input gives the same output, and prove it with a check: run twice, compare row counts and totals, and show the numbers did not change.
The reasoning
**Say the word first: idempotent.** Running the job twice with the same input must leave the destination in the same state as running it once. Interviewers listen for it, and then for how you achieved it.
**Name the mechanism.** The common ones are: overwrite the whole partition for the date (delete and write in one step), upsert on a business key (`INSERT ... ON CONFLICT` or `MERGE`), or write to a staging table keyed on a deterministic hash of the source record and merge into the final table. Plain `append` is not idempotent.
**Say how you know.** A test that runs the job twice on a fixed input and asserts the same row count and the same sum of the key amount. In production, a reconciliation check that compares source and destination counts for each run date.
**Mention the date.** The job takes the run date as an argument and never calls `now()` for its logic, so a rerun for last Tuesday behaves the same as the original run. A late file is handled by event time, not by arrival time.
The answer most people give
"We never run it twice." Reruns happen after failures, after fixes and for backfills. Saying you avoid them tells the interviewer that the pipeline cannot recover.
An upstream API changes a field from a string to an object and your pipeline starts failing. What do you do in the next hour?
Why they ask this
It is an incident question in disguise. The interviewer wants a calm order of actions: stop bad data, find the change, decide, fix, prevent.
Say this
First stop the pipeline so bad data does not land, then look at the actual payload to see the change. Land the raw payload so nothing is lost, decide with the owner whether to coerce, parse or quarantine the new shape, fix and backfill, and add a schema check so it is caught next time.
The reasoning
**Contain.** Pause the job or route failures to a reject area. A pipeline that keeps writing nulls for a broken field is worse than one that stops. Tell the downstream users that data is delayed.
**Look at the facts.** Compare a failing payload with an old one. Is the change in every record or only some? Did a new version of the API roll out? Check the changelog, and ask the owner of the source system whether the change is intentional and permanent.
**Decide and fix.** Options: coerce the new shape into the old one, parse the object into new columns, or quarantine records that do not match. A sound fallback is to **keep the raw payload** (for example in a JSON column) and project typed columns on top, so nothing is lost while the typed layer is fixed. Then backfill the period that failed.
**Prevent.** Add a schema or contract check at the boundary that fails loudly on an unexpected type, a test with a sample of the new payload, and an alert on rejects above a threshold. Say what you would agree with the producer: a notice before breaking changes.
The answer most people give
"I would wrap the field in a try/except and carry on." That hides the change, loads wrong data and leaves nobody aware that the source moved.
They’ll ask next
How would you backfill the days that failed without duplicating the days that worked?
EvergreenData structures & complexityConcurrency (threading vs multiprocessing vs asyncio)GIL & memory
Your Python job used to take 20 minutes and now takes three hours. How do you find out why?
Why they ask this
Performance debugging is a daily task. The interviewer wants to see measurement before changes, and a short list of likely causes.
Say this
Measure first: compare the input size and each step's time with a good run, then profile the slow step. The usual causes are more data, a quadratic pattern such as a list lookup in a loop, row-by-row calls to a database or API, and a lost index or pushed-down filter.
The reasoning
**Start with what changed.** Input volume, the source, the data shape (a new very large key), the environment, or the code. Compare logs from a good run and a bad run: where do the timestamps diverge?
**Measure, do not guess.** Add timing around each step, or use `cProfile` for CPU time and `tracemalloc` for memory. A job can be slow because of waiting (network, database, disk) or because of computing, and the fix is different.
**The usual suspects.** Reading everything into memory and swapping. `x in some_list` or `pop(0)` in a loop (quadratic). One query or API call per row instead of a batch. A merge that multiplied rows. A filter applied after loading instead of in the query. A table whose index or partition pruning stopped working.
**Then fix one thing and measure again.** Say the result as a number: "the step went from 2 h 40 min to 6 min after I replaced the per-row lookups with one batch query". If you only suspect the cause, say that, and say how you would confirm it.
The answer most people give
"I would rewrite it with multiprocessing." That adds complexity before the cause is known, and it does not help if the job is waiting on one slow query.
They’ll ask next
How would you tell whether the job is waiting or computing?
How did you test that pipeline? What did you test, and what did you leave untested?
Why they ask this
Many candidates answer "I ran it and checked the output". The interviewer wants to hear about repeatable tests, small fixtures and edge cases.
Say this
Separate the pure transformation logic from the input and output, then unit-test the logic with small fixtures that include bad rows and empty input, test the boundary with fakes or mocks, and run one end-to-end check on a small sample. Be honest about what is not covered.
The reasoning
**Make the logic testable.** Put the transform in functions that take data and return data, with no file or network access inside. Pass the client and the clock in as parameters. That is what makes tests fast and the same on every run.
**What to cover.** The normal case, an empty input, one row, a row with a missing field, a wrong type, a duplicate, and a boundary value such as midnight or the end of the month. Use `pytest.mark.parametrize` so each case is one line.
**The boundary.** Replace the API or database with a fake or a mock, and assert on what was sent (a retry on a 503, no retry on a 400). Use `monkeypatch` or `unittest.mock.patch` only at the boundary, and prefer injection.
**Say what is not tested.** For example, real data volumes, or the scheduler itself. Then say how you cover the gap: data quality checks in production, row-count reconciliation, and an alert. Naming the gap honestly is a stronger answer than claiming full coverage.
The answer most people give
"I ran it on production data and it looked right." That is one run on one day. It does not catch the edge cases, and nobody can repeat it.
They’ll ask next
Give me one bug your tests caught before production.
Your Python pipeline needs credentials and API keys. Where do they live, and how does the code read them? How do you stop them from leaking into logs?
Why they ask this
A leaked key is a security incident. It is also a check on whether you have run code in a shared environment.
Say this
Keep secrets out of the code and the repository: read them from the environment or a secrets manager at start-up, give each job the least access it needs, and never log them. Mask them in logs and make sure an exception message cannot print a URL or a connection string that contains one.
The reasoning
**Where they live.** In a secrets manager (the cloud provider's, or Vault), or in environment variables injected at run time. Never in the source code, a notebook or a committed `.env` file. Add the file name to `.gitignore`, and scan the repository for secrets in CI.
**How the code reads them.** Read configuration once at start-up, and fail fast with a clear message if a required value is missing. Do not read the environment at import time, which makes tests and imports surprising.
**How they leak.** A secret in a URL query string appears in logs and in `requests` exceptions. A connection string in a traceback. `print(config)` or logging a whole settings object. Log the host and the job name, not the full URL, and give settings objects a `__repr__` that hides secret fields.
**Least privilege and rotation.** Each pipeline gets its own role that can read only its source and write only its destination. Short-lived credentials and rotation limit the damage of a leak. If a key is ever committed, rotate it at once: removing it from the history is not enough.
The answer most people give
"I put the key in a config file that is not shared." Files get shared, copied and committed. Use the environment or a secrets manager.
They’ll ask next
You find a key in the git history. What do you do?
The source system of a data pipeline is down, or returns errors for an hour. What should the pipeline do? What did the pipeline you built do?
Why they ask this
Every external system fails. The interviewer wants retries with limits, a clear failure signal and a way to catch up, not a hang or silent success.
Say this
Use timeouts, retry only transient errors with exponential backoff and a limit, fail loudly when the limit is reached, alert the owner, and make the job resumable so that a later run catches up without loading anything twice.
The reasoning
**Short outage.** A timeout on every call, then bounded retries with backoff and jitter for errors that can heal (timeouts, 429, 5xx). Respect `Retry-After`. Do not retry errors that cannot succeed, such as a 401 or a 404.
**Long outage.** After the retries are used up, the task must **fail**, not return an empty result. The scheduler then marks the run failed, alerts the owner, and can retry the run later. Downstream tasks do not start on missing data.
**Catching up.** Store progress outside the job (a watermark, a cursor or a list of processed files), so the next run starts where the last one stopped. Because the load is idempotent, the catch-up run can overlap with data that was already loaded.
**Tell people.** Say how the on-call person learns about it, and what the dashboard shows during the delay: "data as of 06:00" is honest, and stale data that looks fresh is not.
The answer most people give
"It retries until it works." Without a limit, a permanent failure runs forever, hides the incident and can overload the source when it comes back.
They’ll ask next
How do you stop many workers from retrying at the same moment when the source recovers?
Your pipeline ran and produced data. How do you know that the data is correct? Say where you check, and what you compare against.
Why they ask this
A pipeline that runs is not the same as a pipeline that is right. The interviewer wants checks that can fail and a way to compare with the source.
Say this
Check at three places: validate the input (schema, required fields, ranges), reconcile the output against the source (row counts and totals), and monitor trends (volume, null rate, freshness). Make the checks able to fail the run, and keep rejected rows instead of dropping them.
The reasoning
**Check the input.** Required fields present, types as expected, values in range, keys unique where they should be. Rows that fail go to a reject file with a reason code. The run reports how many were rejected.
**Reconcile the output.** Rows read equals rows written plus rows rejected. The sum of an important amount in the source equals the sum in the destination. A count of rows with a missing key is zero. These numbers are written to a run log.
**Watch trends.** A sudden drop or jump in daily volume, a rising null rate, a data freshness that goes past its limit. A check that cannot fail, for example one that compares the output with itself, proves nothing.
**Be concrete.** Give one example of a check that caught a real problem, and what you did. If you do not have one, describe one you would add, and say why.
The answer most people give
"I looked at a sample and it looked fine." That checks a few rows on one day. It cannot catch a drift or a loss that affects one percent of the rows.
They’ll ask next
What would an alert on the null rate need to avoid false alarms?
Tell me about the worst failure you had in a pipeline you owned. What happened, and what did you change?
Why they ask this
It tests honesty, ownership and learning. Interviewers do not expect that nothing went wrong. They look for what you did and what changed afterwards.
Say this
Pick a real incident and tell it in order: what broke and who was affected, how you found the cause, what you did to fix it, and the permanent change you made so it cannot happen again. Name your own part, and do not blame a person.
The reasoning
**Choose well.** A real failure with a clear cause and a clear fix: duplicates after a retry, a silent schema change, a late file that was skipped. A story with no consequence is not believable, and a story that you caused and fixed shows ownership.
**Tell it in order.** Impact (what was wrong, for how long, for whom), detection (how you found out, and whether a check should have found it earlier), diagnosis (what you looked at), fix (what you did first, and what the permanent fix was).
**End with the change.** A test, a check, an alert, a runbook, or a design change such as making the load idempotent. This is the part the interviewer remembers.
**Keep it blameless.** Describe the system and the gap in it, not the person who made the mistake, even if it was you: "I did not add a check for X, so now we have one".
The answer most people give
"Nothing major ever went wrong." It is not believable for anyone who has run a pipeline in production, and it removes your chance to show how you learn.
They’ll ask next
How long was the data wrong before anyone noticed, and what would shorten that?
48 / 50
EvergreenData structures & complexityConcurrency (threading vs multiprocessing vs asyncio)pandas / polars
The data volume your pipeline handles grows ten times. Which part of the pipeline breaks first? What would you change?
Why they ask this
It tests whether you know where your own design is weak. A good answer names a specific bottleneck and a specific next step, not a list of buzzwords.
Say this
Name the first bottleneck from your own design: memory if you load whole files, a single-threaded step, a database that cannot take the write rate, or run time passing the schedule window. Then say the smallest change that removes it, and when you would move to a distributed engine.
The reasoning
**Find the first limit.** Memory (everything is read into a list or a DataFrame), time (the run no longer fits before the deadline), a rate limit of the source API, or the write capacity of the destination. Say which one you would hit first, and why.
**Use the cheap steps first.** Stream or read in chunks, select only the columns you need, use a columnar format such as Parquet, push filters to the source, batch the writes, and run independent partitions in parallel. For I/O-bound work use threads or `asyncio`, and for CPU-bound work use processes.
**Know when to leave single-machine Python.** When the data no longer fits on one machine or the job needs a shuffle across a lot of data, move the step to Spark or a warehouse engine and keep Python for orchestration and small transforms. Say what would trigger the change: a number, not a feeling.
**Say what you would measure.** Run time per step, memory peak, rows per second, and the source or sink limit. A plan that starts with measurement is more convincing than one that starts with a new technology.
The answer most people give
"I would move everything to Spark." Spark is the right tool in some cases, but proposing it without finding the bottleneck suggests that you reach for tools before you understand the problem.
They’ll ask next
What number would tell you it is time to leave pandas?
How is your Python pipeline packaged, scheduled and deployed? What happens when you need to change it?
Why they ask this
A job that only runs on one laptop is not a pipeline. The question checks that you know about environments, repeatable builds and safe change.
Say this
Describe the chain: code in version control, pinned dependencies, a repeatable build such as a container image, an orchestrator that schedules and retries, config from the environment, and a review and test step before release, with a way to roll back.
The reasoning
**Packaging.** Dependencies pinned in a lock file or a `requirements` file with versions, so that the build is repeatable. A virtual environment or a container image, so the job runs the same on a laptop and in production.
**Scheduling.** An orchestrator (Airflow, Dagster, or a cloud scheduler) runs the job on a schedule, passes the run date, retries failed tasks, and shows the run history and alerts. The job itself does not contain a `while True` loop with `sleep`.
**Configuration.** The same code runs in each environment, and only configuration changes: bucket names, hosts and credentials come from the environment, not from edits to the code.
**Change safely.** Pull request and review, automated tests in CI, a deployment that can be rolled back, and a backfill plan for a change that affects past data. Mention how you test a change on a copy or a small sample before it touches production tables.
The answer most people give
"I run the script with cron on a server and edit it there when something changes." That has no history, no review and no way back, and it breaks as soon as the server is replaced.
They’ll ask next
How do you make sure the dependency versions in production match what you tested?
You rebuild that pipeline today, from the start. What would you do differently, and why?
Why they ask this
It is a maturity question. Candidates who can name real weaknesses of their own work, and the reason for each change, are seen as more senior than those who say they would change nothing.
Say this
Pick two or three real weaknesses of the original design, such as no idempotency, weak testing, hard-coded config or no monitoring, and say for each what you would do now and why. Keep it specific, and keep it about the design, not the tools.
The reasoning
**Choose real weaknesses.** Look at your own list of incidents and annoyances. Reruns that needed manual cleaning, a missing alert, a function that mixed I/O and logic so it could not be tested, a schema that was guessed from the first file.
**Link each change to a reason.** "I would write raw data to a landing zone first, because when the source changed I could not replay what had arrived." A reason shows you learned from experience and not from a checklist.
**Do not say "rewrite in a new tool".** A change of tool is rarely the lesson. Prefer design changes: idempotent loads, contracts at the boundary, tests on pure functions, explicit run dates, and observability.
**Be honest about trade-offs.** Say what the change would cost in time or complexity, and why you did not do it the first time. That is realistic, and it shows judgement.
The answer most people give
"Nothing, it worked fine." Every pipeline has weaknesses, and saying so suggests you did not look for them.
They’ll ask next
Which of those changes would you make first, and why?