The Practical Coding Round
The FDE coding round rarely asks you to invert a binary tree. It hands you a messy file from a client, two systems that disagree, or a script someone else wrote that's broken, and watches how you work. This page solves six of those problems in plain Python — and every lab runs right here in your browser, so you can try each one before reading the answer.
Learning objectives
- Recognise how an FDE coding round differs from an algorithms interview, and what it rewards.
- Clean, reconcile, and summarise messy real-world data with the standard library.
- Call an unreliable API safely with retries, backoff, jitter, and idempotency.
- Debug unfamiliar code methodically and prove the fix with tests.
30-second recap
- Advanced Python for agents: Type hints, dataclasses/Pydantic, async and generators — the Python features agent and API code uses constantly.
- The coding interview: Clarify, outline an approach, code it, test it, state the complexity — and think out loud the whole way.
1 · How the FDE coding round works essential
Candidates who grind algorithm puzzles often stumble here, because the hard part isn't the algorithm. A typical prompt is "here's a CSV export from the client's system — get it into shape so we can load it" or "orders in their ERP don't match their CRM; find out where." The logic is rarely more than loops, dictionaries, and a regular expression. What's hard is that the data is dirty in ways nobody warned you about, the requirements are a bit vague, and the interviewer is watching whether you notice the traps before they bite.
That mirrors the job. An FDE spends far more time making a client's real data and systems behave than writing clever code. So the round rewards a different set of habits: look at the data first before you write anything, ask what "correct" means (drop bad rows, or flag them?), handle the edge cases out loud, write code a client's engineer could maintain after you leave, and prove it works with a test. A solution that's slightly slower but obviously correct and readable beats a clever one-liner every time.
Use the same rhythm on every problem: Inspect the input and name what's messy. Clarify the expected output and the rules for bad data. Build the simplest version that works. Test it on the normal case and the edges. Harden it — what happens at ten times the size, or when the input is malformed? Narrate each step; in this round, your reasoning is graded as much as your code.
- Read left to right: one step after another, each said out loud.
- The first two boxes happen before you type any code — looking at real rows and agreeing what to do with bad ones.
- The green "Test" box is where you prove the normal case and the edges, not just run it once.
- The amber "Harden" box is the senior step: what breaks at scale or on malformed input, and how you'd handle it.
In short: look and ask before you build, and finish by testing the edges and naming what would break next.
2 · Clean a messy client export essential
This is the most common FDE coding prompt, and the trap is silently "fixing" data you don't understand. The sample has dates in three formats, a duplicated row, stray spaces, mixed-case plan names, a blank name, a blank spend, and one date that isn't a real date at all. The interviewer wants to see you notice all of that, decide what to do with each kind of problem, and — crucially — report what you changed instead of quietly guessing.
03/04 as March or April depending on locale, a blank spend becomes a customer who spends nothing, and nobody can see what was changed. Clean-looking output that's quietly wrong is worse than messy output.The strong approach: try a short, explicit list of known date formats, and mark anything else as unparseable. Strip whitespace and normalise case. Drop exact duplicates. Keep missing values as missing (None), not zero. And collect every problem with its line number, so the client can fix the source.
clean_export.pyimport csv, io
from datetime import datetime
RAW = """customer_id,name,signup_date,plan,monthly_spend
C001,Acme Ltd,2026-03-14,pro,1200
C002, Bolt Inc ,14/03/2026,Basic,300
C001,Acme Ltd,2026-03-14,pro,1200
C003,Crane & Co,March 2 2026,PRO,
C004,,2026-04-01,basic,450
C005,Delta GmbH,2026/13/40,pro,980
"""
DATE_FORMATS = ["%Y-%m-%d", "%d/%m/%Y", "%B %d %Y"]
def parse_date(text):
for fmt in DATE_FORMATS:
try:
return datetime.strptime(text.strip(), fmt).date().isoformat()
except ValueError:
continue
return None # unparseable: flag it, don't guess
def clean(raw):
rows, problems, seen = [], [], set()
for line_no, row in enumerate(csv.DictReader(io.StringIO(raw)), start=2):
row = {k: (v or "").strip() for k, v in row.items()}
key = tuple(row.values())
if key in seen:
problems.append((line_no, "duplicate row dropped"))
continue
seen.add(key)
date = parse_date(row["signup_date"])
if date is None:
problems.append((line_no, f"bad date {row['signup_date']!r}"))
if not row["name"]:
problems.append((line_no, "missing name"))
spend = float(row["monthly_spend"]) if row["monthly_spend"] else None
if spend is None:
problems.append((line_no, "missing spend"))
rows.append({"customer_id": row["customer_id"], "name": row["name"] or None,
"signup_date": date, "plan": row["plan"].lower(), "monthly_spend": spend})
return rows, problems
rows, problems = clean(RAW)
for r in rows:
print(r)
print("--- problems ---")
for line_no, msg in problems:
print(f"line {line_no}: {msg}")
csv.DictReaderover an in-memory string handles quoting and the header for you — never split CSV lines on commas by hand (the&in "Crane & Co" is harmless, but a quoted comma isn't).parse_datetries three explicit formats and returnsNonefor anything else, so2026/13/40is flagged instead of guessed.- Duplicates are detected on the whole cleaned row, after stripping whitespace, so formatting differences don't hide them.
- Missing spend stays
None; turning it into0would quietly change the client's revenue numbers. - Every problem is reported with its line number (line 1 is the header), which is what the client needs to fix their export.
Try this: add a row with spend "1,200" and watch float() fail. Decide out loud: strip the comma, or flag the row? That decision is the interview.
Why this lands: you inspected before you built, you refused to guess on ambiguous values, and you produced a problem report alongside the clean data. That's exactly what makes a client trust your data pipeline.
Go deeper: parsing messy text · validators & coercion · data-cleaning pitfalls
3 · Reconcile two systems intermediate
Reconciliation is everyday FDE work, and it tests whether you can compare data from systems that were never designed to agree. Field names differ, IDs are formatted differently, numbers arrive as strings, and statuses use different capitalisation. The interviewer is watching for three things: do you normalise before you compare, do you split the result into clear categories, and do you use the right data structure so it still works on a million records.
==. — It's O(n×m), so it crawls on real volumes; "a-103 " never equals "A-103"; 99.5 never equals "99.5"; and the output is one undifferentiated list of "problems" finance can't act on.reconcile.pyERP = [
{"order_id": "A-100", "amount": 250.00, "status": "shipped"},
{"order_id": "A-101", "amount": 99.50, "status": "open"},
{"order_id": "A-102", "amount": 410.00, "status": "shipped"},
{"order_id": "a-103 ", "amount": 75.00, "status": "cancelled"},
]
CRM = [
{"id": "A-100", "total": "250.00", "stage": "Shipped"},
{"id": "A-101", "total": "99.5", "stage": "Shipped"},
{"id": "A-103", "total": "75.00", "stage": "Cancelled"},
{"id": "A-104", "total": "120.00", "stage": "Open"},
]
def norm_id(value):
return value.strip().upper()
def reconcile(erp, crm):
left = {norm_id(r["order_id"]): (round(r["amount"], 2), r["status"].lower()) for r in erp}
right = {norm_id(r["id"]): (round(float(r["total"]), 2), r["stage"].lower()) for r in crm}
report = {
"only_in_erp": sorted(left.keys() - right.keys()),
"only_in_crm": sorted(right.keys() - left.keys()),
"mismatched": [],
}
for oid in sorted(left.keys() & right.keys()):
if left[oid] != right[oid]:
report["mismatched"].append((oid, left[oid], right[oid]))
return report
report = reconcile(ERP, CRM)
print("only in ERP:", report["only_in_erp"])
print("only in CRM:", report["only_in_crm"])
for oid, erp_val, crm_val in report["mismatched"]:
print(f"mismatch {oid}: ERP={erp_val} CRM={crm_val}")
norm_idstrips whitespace and upper-cases, so"a-103 "and"A-103"are recognised as the same order.- Each side becomes a dictionary keyed by normalised ID, with a comparable value: the amount rounded to cents and the status lower-cased.
- Set operations on the keys give the three categories directly: only in the ERP, only in the CRM, and in both. Each lookup is O(1), so the whole job is O(n + m).
- For orders in both, the tuple comparison finds real disagreements — here A-101 is "open" in one system and "shipped" in the other.
Try this: add a tolerance so amounts within 1 cent count as equal, and decide whether status should be compared at all, or reported separately. Ask the interviewer which disagreements finance actually cares about.
Why this lands: normalisation before comparison, an O(n + m) design with dictionaries and sets, and an output split into categories someone can act on. Asking "which differences matter to finance?" is the FDE touch.
Go deeper: dicts & sets for fast lookups · hashing patterns · SQL joins for the same job
4 · Find the top error causes in a log intermediate
The hidden test is grouping: the same failure appears with different numbers each time. Count raw error messages and every timeout looks unique, because the milliseconds differ. Count by a normalised "signature" and the real picture appears. The interviewer also wants to see you survive malformed lines and inconsistent log levels instead of crashing on the first surprise.
line.split()[2] == "ERROR" and a counter over the full message. — The lower-case error line is missed, the garbled line crashes the split, and three identical vector-db timeouts are counted as three different errors.top_errors.pyimport re
from collections import Counter
LOG = """2026-10-30 09:00:01 INFO request id=1 path=/search
2026-10-30 09:00:02 ERROR request id=2 timeout calling vector-db after 3000ms
2026-10-30 09:00:02 ERROR request id=3 timeout calling vector-db after 3012ms
2026-10-30 09:00:03 WARN request id=4 slow response 2100ms
2026-10-30 09:00:04 ERROR request id=5 KeyError: 'customer_id'
garbled line with no structure
2026-10-30 09:00:05 ERROR request id=6 timeout calling llm-api after 30000ms
2026-10-30 09:00:06 error request id=7 KeyError: 'plan'
2026-10-30 09:00:07 ERROR request id=8 timeout calling vector-db after 2990ms
"""
LINE = re.compile(r"^\S+ \S+ (?P<level>\w+)\s+request id=(?P<id>\d+) (?P<msg>.*)$")
def signature(msg):
msg = re.sub(r"\d+ms", "<N>ms", msg) # numbers vary; the cause doesn't
msg = re.sub(r"'[^']*'", "'<field>'", msg)
return msg
def top_errors(log, n=3):
counts, skipped = Counter(), 0
for line in log.splitlines():
m = LINE.match(line)
if not m:
skipped += 1
continue
if m["level"].upper() == "ERROR":
counts[signature(m["msg"])] += 1
return counts.most_common(n), skipped
top, skipped = top_errors(LOG)
for msg, count in top:
print(f"{count} {msg}")
print(f"(skipped {skipped} unparseable line)")
- One regular expression with named groups pulls out the level, request ID, and message, and simply doesn't match a garbled line — which is counted and skipped instead of crashing.
.upper()on the level catches the lower-caseerrorline too.signaturereplaces the parts that vary (timings, field names) with placeholders, so all three vector-db timeouts collapse into one cause.Counter.most_commonranks the causes. The answer to the client is now clear: most errors are vector-db timeouts.
Try this: extend it to report, for each cause, the first and last time it appeared. "Started at 09:00 and still happening" is a far more useful message to a client than a count.
Why this lands: you normalised before counting, handled the messy lines gracefully, and turned raw logs into a ranked answer a client can act on. Naming the skipped-line count shows you don't hide data you couldn't parse.
Go deeper: regular expressions in practice · logging properly · the monitoring signals
5 · Call a flaky API safely advanced
The word that decides this question is "exactly". Retrying is easy; retrying without creating duplicates is the real problem. The simulated API here behaves like real ones: sometimes it fails before doing anything, and sometimes it times out after creating the invoice. A naive retry after that second kind of failure creates a second invoice. The interviewer wants retries with backoff and jitter, a cap on attempts, and idempotency so repeats are harmless.
while True: loop that retries immediately on any exception. — No backoff means you hammer an API that's already struggling; no attempt cap means a permanent failure loops forever; catching every exception retries bugs that will never succeed; and with no idempotency key, every "timeout after success" creates a duplicate invoice.flaky_api.pyimport random
rng = random.Random(7) # fixed seed: the same "flakiness" every run
processed = {} # server-side store keyed by idempotency key
class TransientError(Exception):
pass
def create_invoice(payload, idempotency_key):
"""Simulated client API: fails ~50% of the time, sometimes AFTER doing the work."""
roll = rng.random()
if roll < 0.3:
raise TransientError("503 Service Unavailable")
if idempotency_key not in processed:
processed[idempotency_key] = {"invoice": f"INV-{len(processed) + 1}", **payload}
if roll < 0.5:
raise TransientError("timeout (but the invoice was created)")
return processed[idempotency_key]
def with_retries(fn, *args, attempts=5, base=0.1, cap=2.0, sleep=lambda s: None):
for attempt in range(1, attempts + 1):
try:
return fn(*args)
except TransientError as err:
if attempt == attempts:
raise
delay = rng.uniform(0, min(cap, base * 2 ** attempt)) # full jitter
print(f" attempt {attempt} failed ({err}); retrying in {delay:.2f}s")
sleep(delay)
for order in ["ORD-1", "ORD-2", "ORD-3"]:
result = with_retries(create_invoice, {"order": order}, f"invoice-{order}")
print(order, "->", result["invoice"])
print("invoices created:", len(processed))
random.Random(7)fixes the seed, so the simulated failures are the same on every run — useful for teaching, and for writing tests against retry logic.- The API stores results by
idempotency_key. When a retry arrives for work it already did, it returns the existing invoice instead of creating another. with_retriescatches onlyTransientError— real bugs should fail loudly, not be retried — and stops after five attempts.- The delay grows exponentially with a cap, and "full jitter" picks a random point in that window, so many clients retrying at once don't all hit the server at the same moment. (
sleepis a no-op here so the lab runs instantly; in real code it'stime.sleep.) - The result: three orders, several failures each, and exactly three invoices — including for ORD-1, whose first call timed out after the invoice was created.
Try this: delete the idempotency check in create_invoice and run it again. Count the invoices. That number is the bug you'd have shipped to the client's finance team.
Why this lands: you separated transient from permanent errors, bounded the retries, spread them out with jitter, and made duplicates impossible with an idempotency key. Saying "exactly-once needs idempotency, retries alone can't give it" is the line that earns the senior signal.
Go deeper: retries done right · idempotency · a thundering-herd outage · queues & delivery guarantees
6 · Debug someone else's code professional
This round tests method under unfamiliarity, not speed. You didn't write the code, you don't know its history, and the bug report is vague. The interviewer wants to watch you reproduce the bug with a concrete input, find the cause, make the smallest correct fix, and add tests that would have caught it — including the edge cases right on each threshold, which is where pricing bugs live.
>=", changing it, and declaring it fixed. — No reproduction, so you can't be sure you fixed the reported bug; no tests, so you can't show it; and in this case the real bug is the order of the conditions, not the comparison operator.debug_discount.py# Someone else's function. Reported bug: "big orders get the wrong discount".
def discount_buggy(total):
if total > 100:
return total * 0.05
elif total > 500:
return total * 0.10 # never reached: 600 > 100 already matched above
return 0
# Fixed: check the most specific (largest) threshold first, and keep money exact.
from decimal import Decimal, ROUND_HALF_UP
def discount(total):
total = Decimal(str(total))
if total > 500:
rate = Decimal("0.10")
elif total > 100:
rate = Decimal("0.05")
else:
rate = Decimal("0")
return (total * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
print("buggy 600 ->", discount_buggy(600)) # 30.0, expected 60
# Tests: the failing case first, then the edges either side of each threshold.
cases = [(600, "60.00"), (100, "0.00"), (100.01, "5.00"), (500, "25.00"), (500.01, "50.00"), (0, "0.00")]
for total, expected in cases:
got = discount(total)
assert got == Decimal(expected), (total, got, expected)
print("all", len(cases), "tests pass")
- Reproduce:
discount_buggy(600)returns30.0. A 10% discount on 600 should be 60 — the bug is confirmed with a concrete case. - Cause: the checks run top to bottom.
600 > 100matches first, so the> 500branch can never run. Checking the largest threshold first fixes it. - A second, quieter fix: money is computed with
Decimaland rounded to cents, because floating-point arithmetic like0.1 + 0.2doesn't give exact decimal results. - Prove it: the test cases cover the reported bug and the values just either side of each threshold.
assertstops with the failing case if anything is wrong.
Try this: ask the question a real FDE would: is "over 500" meant to include exactly 500? The code and the business rule may disagree, and only the client can say which is right. Write the test for whichever answer they give.
Why this lands: reproduce, explain, fix minimally, then prove it with tests on the boundaries. Flagging the float-versus-decimal issue and the "is 500 included?" question shows you think about the business rule, not just the code.
Go deeper: testing your own code in the interview · testing the edges · reading a failure
7 · Build a tested pipeline step: chunking for RAG tech-lead
"Production quality" is the phrase to listen for. A basic chunker is ten lines; a production one has a clear contract, rejects bad parameters, never cuts a word in half, handles empty input and words longer than a chunk, and comes with tests that pin all of that down. At tech-lead level, the interviewer also wants you to explain the trade-offs: why overlap exists, and what you'd change for real documents with headings and tables.
[text[i:i+40] for i in range(0, len(text), 30)] — one line, and it works on the happy path. But it cuts words in half ("refun" / "ded"), accepts nonsense like an overlap bigger than the chunk, and has no tests, so nobody knows what it promises.chunker.pydef chunk(text, size=40, overlap=10):
"""Split text into word-safe chunks of <= size chars, each sharing ~overlap chars with the last."""
if size <= 0 or not 0 <= overlap < size:
raise ValueError("need size > 0 and 0 <= overlap < size")
words, chunks, current = text.split(), [], []
for word in words:
if len(word) > size:
raise ValueError(f"word longer than chunk size: {word[:20]}...")
if current and len(" ".join(current + [word])) > size:
chunks.append(" ".join(current))
tail = [] # carry the last ~overlap chars forward
for w in reversed(current):
if len(" ".join([w] + tail)) > overlap:
break
tail.insert(0, w)
current = tail
if current and len(" ".join(current + [word])) > size:
current = [] # overlap would overflow: drop it
current.append(word)
if current:
chunks.append(" ".join(current))
return chunks
DOC = ("Refunds are issued within 14 days of a return. Items must be unused "
"and in their original packaging. Sale items cannot be refunded.")
SIZE = 40
parts = chunk(DOC, size=SIZE)
for i, p in enumerate(parts):
print(i, repr(p))
# tests
assert all(len(p) <= SIZE for p in parts) # size limit holds
for a, b in zip(parts, parts[1:]): # neighbours share text
assert a.split()[-1] in b.split()[:3], (a, b)
words_out = " ".join(parts).split()
assert all(w in words_out for w in DOC.split()) # no words lost
assert chunk("") == [] # empty input
assert chunk("one two") == ["one two"] # short input = one chunk
try:
chunk("x", size=10, overlap=10)
raise AssertionError("expected ValueError")
except ValueError:
pass
print("all tests pass")
- The function validates its parameters first and raises a clear
ValueErrorfor impossible ones — fail fast at the boundary instead of producing strange chunks. - It builds chunks from whole words, so nothing is ever cut mid-word; a single word longer than the chunk size is rejected rather than silently split.
- When a chunk is full, the last few words (up to about
overlapcharacters) carry over into the next chunk, so a sentence that straddles the boundary appears in both. - The tests pin down the contract: every chunk fits, neighbours share text, no words are lost, empty and short inputs behave, and bad parameters raise.
Try this: change SIZE to 25 and re-run — the tests still pass, which is the point of testing the contract instead of specific outputs. Then discuss: for real policy documents you'd split on headings and sections first, and only fall back to size-based chunking inside a long section.
Why this lands: a clear contract, validated inputs, word-safe behaviour, and tests that describe what the function promises. Explaining when you'd chunk by document structure instead shows you're designing for the client's real data, not the sample.
Go deeper: chunking strategies in depth · chunking text · tests with pytest · unit testing
🪜 Practice ladder beginner → industry
- Beginner: run Lab FI6.1, then add two more messy rows of your own and make sure each one is either cleaned or reported.
- Easy: change Lab FI6.3 to also report each error cause's first and last timestamp.
- Core: extend Lab FI6.2 with a one-cent amount tolerance and a summary line ("3 of 5 orders match").
- Stretch: in Lab FI6.4, make the retry give up early on a permanent error such as "400 Bad Request", and add a test for it.
- Hard: rewrite the chunker in Lab FI6.6 to split on blank lines (paragraphs) first and only fall back to size-based chunks for long paragraphs. Keep all the tests passing.
- Industry: do a timed mock: a friend gives you a real messy CSV you've never seen, and you have 30 minutes to clean it, test it, and explain your choices out loud.
✓ Checkpoint — you can move on when you can…
- Explain how the FDE coding round differs from an algorithms interview.
- Clean messy data without silently guessing, and report what you changed.
- Reconcile two datasets in O(n + m) with dictionaries and sets.
- Write retry logic with backoff, jitter, a cap, and idempotency.
- Reproduce, fix, and test a bug in code you didn't write.
Knowledge check check yourself
When cleaning a client's data export, why should an unparseable date or a blank value be flagged rather than filled in?
Show answer
Why aren't retries alone enough to make sure each order gets exactly one invoice from a flaky API?