In this post13 sections
  1. Why the bad rows are the point
  2. Look before you parse: encoding, delimiter and headers
  3. Normalize dates, money and names without guessing
  4. The rejects report: every dropped row and why
  5. Make the run safe to repeat
  6. The same task in published FDE take-homes
  7. Narrate it like a customer conversation
  8. Follow-ups to expect
  9. Mistakes that cost the most
  10. Practice it before the round
  11. Questions people ask
  12. Keep reading
  13. More from the blog

The interviewer drops a file into the shared editor and says, “The customer sent us their account export. Load it.” You open it and the first row has a strange character before the header, the delimiter is a semicolon, and one date reads 03/04/2026. Here is the answer, short: read the bytes before you parse, use Python’s csv module instead of splitting on commas, normalize each field with explicit rules, and send every row you can’t fix to a rejects report with a reason. Then make the load safe to run twice, and tell the interviewer, as if they were the customer, what you dropped and what you need them to decide. This post works through that one prompt; the FDE interview guide covers the rest of the loop.

Why the bad rows are the point

A clean file tests whether you can call csv.reader. A dirty one tests whether a customer could trust what you load. The Pragmatic Engineer reported, in August 2025, that Colin Jarvis, then head of forward deployed engineering at OpenAI, said what customers describe in scoping often does not match the data and system reality on the ground. Source 1What are Forward Deployed Engineers, and why are they so in demand? (Gergely Orosz)PublisherThe Pragmatic EngineerSource typenews report The messy CSV is that gap, shrunk to fit a single round.

The prompt is old. One candidate reported, in a Glassdoor review from October 2016, being asked “Write a method to parse a CSV file” in a phone screen for Palantir’s role. Source 2Palantir Technologies Forward Deployed Software Engineer Interview Questions (archived)PublisherGlassdoor (archived by the Wayback Machine)Source typecandidate’s personal write-up Palantir’s own coding-interview guide tells candidates to think about edge cases and the ways their code could break. Source 3Writing Good CodePublisherPalantirSource typecompany hiring page Our lesson on what FDE coding rounds test sets out which companies’ reports point to practical builds like this one.

Treat every bad row as something you are meant to find. The bar we hold an answer to has three parts:

  • Nothing vanishes. Every row ends up loaded, skipped with a count, or rejected with a reason.
  • Nothing is guessed silently. A value with two readings is flagged, not picked.
  • Someone can act on the result. The rejects report is written for the person who owns the source system.

Look before you parse: encoding, delimiter and headers

Spend the first two minutes reading, not typing. Three checks catch encoding, delimiter and header problems before they turn into confusing errors.

import csv, pathlib

def read_text(path):
    raw = pathlib.Path(path).read_bytes()
    for enc in ("utf-8-sig", "cp1252"):
        try:
            return raw, raw.decode(enc), enc
        except UnicodeDecodeError:
            continue
    raise ValueError("unknown encoding: ask")

def sniff(text):
    sample = "\n".join(text.splitlines()[:20])
    return csv.Sniffer().sniff(sample, ",;\t|")

The byte order mark. Files saved as UTF-8 by Windows tools often start with a byte order mark. Decode that with plain utf-8 and your first column is named id, so a lookup for id fails and you lose ten minutes. The utf-8-sig codec skips the mark if it is there and reads normal UTF-8 if it isn’t, as the Python docs for the utf-8-sig codec describe.

The fallback encoding. If the bytes are not valid UTF-8, the file probably came from an older Windows export. Say out loud that cp1252 will decode almost any bytes, so success proves nothing. Record which encoding you used, and check that a name like Café Lumen reads correctly.

The delimiter. csv.Sniffer guesses the dialect from a sample, and restricting it to four likely delimiters stops it from choosing a letter. It can still guess wrong, or raise csv.Error, so treat the header as the real check: if the required columns are not in it, stop and say “wrong file or wrong delimiter”, don’t push on. A semicolon is also a hint. Microsoft’s guide to importing and exporting text files notes that a comma decimal separator makes Excel use a semicolon as the list separator, so expect amounts like 1.234,56.

Normalize dates, money and names without guessing

The rule we use: fix what has one reading, reject what has two, and say which is which. Stray whitespace has one reading. 03/04/2026 has two.

import re
from datetime import datetime

class Reject(Exception):
    def __init__(self, reason, detail):
        super().__init__(detail)
        self.reason = reason

FORMATS = ("%Y-%m-%d", "%d.%m.%Y", "%d %b %Y")
SLASH = re.compile(r"(\d{1,2})/(\d{1,2})/\d{4}")

def parse_date(s):
    if m := SLASH.fullmatch(s):
        both = max(int(m[1]), int(m[2])) <= 12
        why = ("ambiguous_date" if both
               else "slash_date")
        raise Reject(why, f"{s!r}: order not set")
    for fmt in FORMATS:
        try:
            return datetime.strptime(s, fmt).date()
        except ValueError:
            pass
    raise Reject("bad_date", f"{s!r}: no such date")

Dates. List the formats you accept and try each one. Never hand the column to a parser that guesses per row, such as dateutil.parser.parse, or pandas with format='mixed': 03/04/2026 comes back as the fourth of March and 13/04/2026 as the thirteenth of April, with no warning. strptime also rejects dates that don’t exist, so 2026-06-31 becomes a bad_date reject, not a crash. A slash date is ambiguous_date when both parts could be the month, and slash_date when only the file-wide order is missing. Dotted dates such as 04.03.2026 are day-first by convention in the locales that use them; that is still an assumption, so name it.

import unicodedata
from decimal import Decimal

SYMBOLS = {"€": "EUR", "$": "USD", "£": "GBP"}
CENT = Decimal("0.01")
# decimal comma: decided once for this file
EU = re.compile(r"(\d{1,3}(?:\.\d{3})+|\d+)"
                r"(?:,(\d\d?))?")

def parse_money(s):
    sym = s[:1] if s[:1] in SYMBOLS else s[-1:]
    body = s.removeprefix(sym).removesuffix(sym)
    m = EU.fullmatch(body.strip())
    if sym not in SYMBOLS or not m:
        raise Reject("bad_amount", f"{s!r}")
    whole = m[1].replace(".", "")
    value = Decimal(f"{whole}.{m[2] or 0}")
    return value.quantize(CENT), SYMBOLS[sym]

def clean_name(s):
    s = unicodedata.normalize("NFC", s)
    return " ".join(s.split())

Money. Three decisions, each said aloud:

  • Use Decimal, never float, because currency needs exact cents. The pattern allows at most two decimals, and every amount comes out with exactly two, so €99 and €99,00 compare equal.
  • Decide the decimal separator once for the file and confirm it. This pattern is the decimal-comma version; for a decimal-point file, swap \. and ,. A group separator may only sit in front of exactly three digits, so €12.50 is rejected rather than read as twelve hundred and fifty.
  • Keep the currency as its own field and never convert. A dollar amount written in the file’s own format, such as $2.000,00, loads with currency USD, so put a count per currency in your summary.

Names and emails. Collapse whitespace and apply Unicode NFC so the same accented name compares equal however it was typed. Lowercase emails. Don’t title-case names: that breaks McDonald and van der Berg, and the customer will notice. Near-duplicate names such as Acme Ltd and ACME Limited are a different problem; the question on deduplicating fuzzy customer records covers it.

The rejects report: every dropped row and why

First, one function turns a row of cells into a record, mapping the export’s column names to yours. Any failure raises a Reject.

REQUIRED = ("id", "name", "renewal", "amount")

def to_record(cells, header):
    if len(cells) != len(header):
        n = len(cells)
        raise Reject("field_count", f"{n} fields")
    row = dict(zip(header, cells))
    if empty := [k for k in REQUIRED if not row[k]]:
        raise Reject("missing_field", ", ".join(empty))
    value, currency = parse_money(row["amount"])
    renewal = parse_date(row["renewal"])
    return {
        "account_id": row["id"],
        "account_name": clean_name(row["name"]),
        "renewal_date": renewal.isoformat(),
        "contract_value": str(value),
        "currency": currency,
        "owner_email": row.get("email", "").lower(),
    }

Next, the reading loop. It remembers the line each record starts on, because a quoted address can span several lines.

import io
from collections import Counter

def records(lines, dialect):
    reader = csv.reader(lines, dialect, strict=True)
    start = 1
    while True:
        try:
            cells = [c.strip() for c in next(reader)]
        except StopIteration:
            return
        except csv.Error as e:
            cells = Reject("bad_quotes", str(e))
        end = reader.line_num
        span = f"{start}-{end}" if end > start \
            else str(start)
        raw = "".join(lines[start - 1:end])
        yield cells, span, raw
        start = end + 1
def parse(text, dialect):
    lines = io.StringIO(text, newline="").readlines()
    rows = records(lines, dialect)
    header = [h.lower() for h in next(rows)[0]]
    if missing := set(REQUIRED) - set(header):
        raise ValueError(f"wrong file? no {missing}")
    good, rejects, skipped = [], [], Counter()
    for cells, span, raw in rows:
        try:
            if isinstance(cells, Reject):
                raise cells
            if not any(cells):
                skipped["blank_line"] += 1
            elif [c.lower() for c in cells] == header:
                skipped["repeated_header"] += 1
            else:
                rec = to_record(cells, header)
                good.append((span, raw, rec))
        except Reject as r:
            why = (r.reason, str(r))
            rejects.append((span, *why, raw))
    loaded = dedupe(good, rejects, skipped)
    return loaded, rejects, skipped

Last, duplicates. Exact copies are skipped. When the same ID arrives with different values, every version is rejected, because choosing one is the customer’s call.

def dedupe(good, rejects, skipped):
    first, clash = {}, {}
    for row in good:
        key = row[2]["account_id"]
        if key not in first:
            first[key] = row
        elif first[key][2] == row[2]:
            skipped["exact_duplicate"] += 1
        else:
            clash.setdefault(key, [first[key]])
            clash[key].append(row)
    for key, versions in clash.items():
        del first[key]
        why = f"{len(versions)} versions of {key}"
        for at, raw, _ in versions:
            rejects.append((at, "conflict", why, raw))
    return [rec for _, _, rec in first.values()]

What each choice buys you:

  • strict=True makes a malformed quote such as "Harbor "Blue" Foods" raise csv.Error instead of quietly merging text. The reader carries on at the next line, so one bad quote costs one row.
  • The raw text goes in the report with its line range, so the owner can find it in their system. A quote that never closes shows up as one reject covering many lines, such as 8-40, which tells you where to look.
  • Structure is skipped and counted, data is rejected. Blank lines and a repeated header aren’t bad data. A repeated header usually means someone pasted two exports together, which is a reason to check for overlap.
  • Exact duplicates are skipped; conflicting ones are all rejected. Two rows with the same ID and different values need a human to say which one wins, so neither is loaded.

Here is the test file we used. It also has a byte order mark and Windows line endings, which you can’t see:

id;name;renewal;amount;email
A1;  Café   Lumen ;2026-01-15;€1.234,56;Ana@X.co
A2;"Nordic; Oslo";15.03.2026;2.000,00 €;b@x.co
A3;Acme Ltd;03/04/2026;€500,00;c@x.co
A4;"Harbor "Blue" Foods";2026-02-01;€10;d@x.co
id;name;renewal;amount;email
A5;Delta;2026-06-31;€10,00;e@x.co
A6;Echo;2026-05-01;$2,000.00;f@x.co
;Foxtrot;2026-05-01;€10,00;g@x.co
A7;Golf;01 May 2026;€99;h@x.co
A7;Golf;01 May 2026;€99,00;h@x.co

Then close with one check you say out loud: loaded plus rejected plus skipped equals the number of data rows. On that file, the output read like this:

ReasonRaw valueAsk the owner
ambiguous_date03/04/2026Day-first or month-first?
bad_quotes"Harbor "Blue" Foods"Can the export escape quotes?
bad_date2026-06-31What was meant?
bad_amount$2,000.00Why is this in a different format?
missing_fieldblank idIs this a real account?

Three loaded, five rejected, one repeated header and one exact duplicate skipped, and all ten accounted for. The two A7 rows count as one duplicate because €99 and €99,00 normalize to the same value. $2,000.00 was rejected because it uses the other decimal separator, not because it is in dollars. That table is what you would send the customer. In our view it is the strongest part of the answer, so leave time to present it.

Make the run safe to repeat

Load jobs fail halfway and get retried. Customers send the same file twice. Re-run-safe means running the file a second time changes nothing.

import hashlib

# needs: accounts.account_id TEXT PRIMARY KEY
#        loads.sha256 TEXT PRIMARY KEY
UPSERT = """
INSERT INTO accounts (account_id, account_name,
  renewal_date, contract_value, currency, owner_email)
VALUES (:account_id, :account_name, :renewal_date,
  :contract_value, :currency, :owner_email)
ON CONFLICT (account_id) DO UPDATE SET
  account_name = excluded.account_name,
  renewal_date = excluded.renewal_date,
  contract_value = excluded.contract_value,
  currency = excluded.currency,
  owner_email = excluded.owner_email"""
SEEN = "SELECT 1 FROM loads WHERE sha256 = ?"
LOG = """INSERT INTO loads (sha256, n_rows, loaded_at)
VALUES (?, ?, datetime('now'))"""

def load(conn, raw, recs):
    digest = hashlib.sha256(raw).hexdigest()
    with conn:  # one transaction: all lands, or none
        if conn.execute(SEEN, (digest,)).fetchone():
            return "already loaded"
        conn.executemany(UPSERT, recs)
        conn.execute(LOG, (digest, len(recs)))
    return f"loaded {len(recs)}"

Three layers, each for a different failure:

  • A file fingerprint. The hash of the raw bytes goes in a loads table with a timestamp, so the identical file is skipped and you can say when it was loaded.
  • An upsert on a stable key. Overlapping exports update rows instead of duplicating them. The ON CONFLICT target must be a primary key or unique column, which is why the comment names the constraint. SQLite’s upsert syntax is the ON CONFLICT ... DO UPDATE clause; Postgres has the same one.
  • One transaction. A crash halfway leaves no half-loaded file, so a retry starts clean.

Then name the limits before the interviewer does. If exports can arrive out of order, an older file with a new hash would overwrite newer values, so store the export’s timestamp and update only when it is newer. And an upsert never deletes: an account missing from this week’s file stays loaded. Finding what was removed or changed between two exports is the question on diffing two exports. The same idempotency idea, applied to API calls, is in our post on the API integration round.

The same task in published FDE take-homes

Take-homes give you time to do all of this properly, and public briefs show the same shape:

  • Tangible: feed validation and re-run-safe ingestion of a fictional client’s positions CSV, then a follow-up email to the client’s integration lead. Source 4Take-Home Assignment: Client Positions Feed OnboardingPublisherTangible (tangiblemarkets on GitHub)Source typecompany website
  • Concourse: one candidate posted a brief to GitHub in September 2026, in a repo described as a Concourse take-home, that supplies a deliberately messy synthetic account export and asks for a cut list of what the candidate chose not to do. Source 5Forward Deployed Engineer: Take-Home (PDF)Publishercgillespie529-gif (GitHub)Source typecandidate’s take-home repositorySource 6vibe-coding (repository description)Publishercgillespie529-gif (GitHub)Source typecandidate’s take-home repository
  • Darwinbox: one candidate posted a brief to GitHub in September 2026, in a repo described as a Darwinbox FDE take-home, that asks for an to clean messy HR and CRM exports into a target schema and escalate only the ambiguous cases to a human; the brief itself does not name the company. Source 7Take-Home Task Brief: Forward Deployed Engineer (PDF)PublisherManoharUchiha (GitHub)Source typecandidate’s take-home repository

In live rounds, one public interview brief has the interviewer play the customer while the candidate works over a deliberately messy dataset. Source 8Second-stage interview — Forward Deployed Engineer (AI)PublisherAxium Industries (GitHub organization)Source typecompany hiring page Notice what repeats: a messy file, a customer on the other end, and a written account of what you did. For how to scope a longer version, see our take-home post.

Narrate it like a customer conversation

The code above is half the round. The other half is what you say while you write it. These lines are our method, not a script any interviewer asked for; use your own words.

Before you parse: “Before I write anything, I’d like to look at the raw file. I see a byte order mark and semicolons, so I’ll read it as UTF-8 with the mark stripped and confirm the delimiter against the header.”

At the first ambiguous value: “This date could be the third of April or the fourth of March. I won’t guess, because a wrong renewal date is worse than a missing one. I’ll reject it with a reason and put it on the list of questions for whoever owns this export.”

When you set a file-wide rule: “I’m assuming a decimal comma for this file because of the semicolons and the euro values. That’s one decision for the file, not for each row, and I’d confirm it with the customer before running it for real.”

At the end: “Three rows loaded, all in euros, five rejected and two skipped, and that accounts for all ten. The rejects file has each row, its line and the reason. Before the next load, I need three answers: the date order, why one amount uses a different format, and whether the row with no ID is a real account.”

That last line is the one to rehearse. It turns a parsing task into the start of a working relationship, the same skill a customer round asks you to show. To practice that half, run the free practice case: it puts you in front of an AI customer who answers only what you ask, and it is free with a sign-in. The lesson on inputs, owners and freshness goes further on asking who owns each source and how fresh it is. When the file is the start of an open-ended prompt rather than a load job, our post on decomposition with a dataset picks up from the columns.

Follow-ups to expect

“The file doesn’t fit in memory.” Stream it. Pass the open file to csv.reader instead of a list of lines, hold only the current record’s raw text, keep only a set of seen IDs with a hash of each record, write rejects to disk as you go, and upsert in batches of a few thousand rows.

“The customer added a column in the middle.” The header is matched by name, so nothing shifts and the new column is ignored. Log a warning for unknown columns: the export changed, and the owner should know.

“Can I just use pandas?” Ask, and if yes, still count rows in and out. On our test file, with a recent pandas release, read_csv(..., dtype=str, keep_default_na=False, encoding="utf-8-sig", sep=None, engine="python") loaded nine rows: the Harbor row was dropped without a word, and a callable passed as on_bad_lines was never called. With sep=";" and the default engine, it loaded all ten, with the name mangled into Harbor Blue" Foods". Either way, you still need your own rejects list.

Mistakes that cost the most

  • line.split(","). It breaks on the first quoted delimiter, such as "Nordic; Oslo" in a semicolon file or an address in a comma one. Use the csv module: its default dialect handles quoted delimiters and quotes written twice ("") the way Excel writes them, which is the quoting that RFC 4180 describes.
  • except: continue. The output looks clean and the missing rows turn up weeks later, when someone reconciles totals. Every except should append to the rejects list.
  • Fixing what you should ask about. Converting currencies, inventing a missing ID or choosing between conflicting rows are business decisions. Flag them.
  • Coding before reading. List the inputs that will break your parser before you write it; the question on edge cases first drills exactly that habit.
  • Silence at the end. If you don’t say how many rows you dropped and why, the interviewer has to ask, and you lose your best chance to show judgment.

Practice it before the round

Copy the test file above, add a quote that never closes and a slash date like 13/04/2026, and write the parser once from a blank file. Time yourself, then say the closing summary out loud. The related questions on diffing exports, fuzzy deduplication and flattening versioned JSON are in Pro, which starts with a 7-day free trial.

Write your parser, then open the free messy CSV question: its model answer settles the slash order for the whole file and caps runaway quotes.

GlossaryForward deployed software engineerPalantir’s title for its FDE role, called Delta internally; OpenAI and EY also post FDSE titles, each with its own duties.More on Forward deployed software engineerGlossaryForward deployed engineerA software engineer who builds and ships production systems inside a customer’s problem and environment, accountable to that customer’s outcome.More on Forward deployed engineerGlossaryAgentA system in which a model chooses steps and tool calls to complete a task, within limits the design sets.More on Agent

Questions people ask

How do you handle a messy CSV in a coding interview?

Inspect the file before parsing, use a real CSV parser rather than splitting on commas, normalize each field with explicit rules, and send every row you cannot fix to a rejects list with a reason. Finish by reporting how many rows loaded and were rejected, and the questions you would ask the data owner.

Should I drop bad rows or fix them?

Fix only what has one reasonable reading, such as stray whitespace or a repeated header row. When a value is ambiguous, such as a date that could be day-first or month-first, reject or flag it and say so, because a silent guess corrupts data the customer trusts.

What does re-run-safe ingestion mean?

Running the same file twice leaves the same result as running it once. Key each record on a stable identifier and upsert, or record which files were already loaded, so a retry after a crash creates no duplicates. Tangible’s public FDE take-home asks for re-run-safe ingestion of a client’s positions CSV.Source 4Take-Home Assignment: Client Positions Feed OnboardingPublisherTangible (tangiblemarkets on GitHub)Source typecompany website

Can I use pandas in a CSV coding interview?

Ask the interviewer. The standard library csv module handles quoting and needs no install, and pandas is quicker to write for large transforms. Whichever you use, count rows in and out and keep your own rejects report, because pandas can drop or mangle a malformed row without flagging it.

Keep reading