Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

CODING CHALLENGE · N°59

CSV → Schema Mapper

Medium FDEDataIntegrations

Every customer hands you a messy export. Before it can flow into your system, each row must be coerced to a schema — strings to ints, "true" to booleans — and the rows that don’t fit quarantined, not silently dropped. Build the mapper. Solve it in Python or TypeScript, with hidden tests.

The problem

Implement map_rows(rows, schema). rows is a list of dicts (parsed CSV records) and schema maps each required field name to a type: "int", "float", "bool", or "str". For each row, coerce every schema field to its type. A row is valid only if all schema fields are present and every value coerces; otherwise it counts as an error. Return {"valid": [coerced rows], "errors": count}.

SOLVE IT YOURSELF

Your turn — write it

Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.

YOUR TASK

Implement map_rows(rows, schema): for each row try to build a coerced record over the schema fields; on any missing field or bad value, count it as an error instead.

CPython · WebAssembly
Examples and constraints 4 examples
EXAMPLE 1
Input rows = [{"age":"30","name":"al"},{"age":"x","name":"bo"},{"name":"cy"}], schema = {"age":"int","name":"str"}
Output {"valid": [{"age": 30, "name": "al"}], "errors": 2}
row 2 fails coercion ("x"→int), row 3 is missing "age"
EXAMPLE 2
Input rows = [{"ok":"true"},{"ok":"false"},{"ok":"maybe"}], schema = {"ok":"bool"}
Output {"valid": [{"ok": True}, {"ok": False}], "errors": 1}
"false" must load as False; "maybe" is not an accepted spelling
EXAMPLE 3
Input rows = [{"n":"1","extra":"z"}], schema = {"n":"int"}
Output {"valid": [{"n": 1}], "errors": 0}
columns the schema does not name are dropped, not counted as errors
EXAMPLE 4
Input rows = [{"a":"1"}], schema = {"a":"int","b":"str"}
Output {"valid": [], "errors": 1}
edge case: one missing field quarantines the whole row, even though "a" coerces
CONSTRAINTS
  • Coerce: int("30")→30, float("1.5")→1.5, bool: "true"/"1"→True, "false"/"0"→False (anything else fails), str: leave as-is.
  • A missing schema field or a failed coercion makes the whole row an error (not partially valid).
  • Preserve field order as given in the schema; return the count of error rows, not the rows themselves.
Hints 4 ideas
HINTS — 4 IDEAS
  1. Write a coerce(value, type) helper that raises/throws on a bad value.
  2. For each row, loop the schema fields in order; if a field is missing or coercion fails, mark the row an error.
  3. Booleans are the tricky type: only a small set of strings are valid; everything else fails.
  4. Return the valid coerced rows and just the count of errors.
Approach, complexity & discussion — open after you solve

Start from brute force

The obvious version leans on the built-in constructors: for each cell call int, float, bool or str, keep whatever converts, and put None where a conversion fails. It is O(R · F) time and space for R rows and F schema fields, the same as the right answer, and it is wrong in two ways. bool("false") is True in Python, because any non-empty string is truthy, so "false" and "0" both load as true. And a row with one bad cell survives as a half-filled record, while the spec says that row must be quarantined whole and counted once.

The approach

Treat each row as all-or-nothing. The reference map_rows builds a fresh record by walking schema.items() in order. A field that is not in the row raises at once, and every present value goes through _coerce(value, t), which is strict: int() and float() raise ValueError on text that is not a number, "bool" accepts only the six exact strings true, True, 1, false, False and 0, and "str" passes the value through.

The whole inner loop sits inside one try. If anything raises, the except discards the partial record and adds 1 to errors, so a row with two bad cells still counts once. record is appended to valid only after every field succeeds, and that is the invariant: everything in valid is complete, typed, keyed in schema order, and free of columns the schema does not name. The TypeScript coerce adds a /^-?\d+$/ check before parseInt, because parseInt("12abc", 10) would return 12.

Walk through an example

On the first example one row coerces cleanly, one fails coercion and one is missing a field, so the result is one valid record and two errors.

steprowfieldvalueoutcomestate after
10age"30"int("30") → 30record = {"age": 30}
20name"al"kept as "al"; row completevalid = [{"age": 30, "name": "al"}], errors = 0
31age"x"int("x") raises ValueError; name is never reachedrecord discarded, errors = 1
42ageabsentraises "missing"errors = 2
5———return{"valid": [{"age": 30, "name": "al"}], "errors": 2}

Complexity

O(R · F) time for R rows and F schema fields, since each cell is checked and converted once (strictly, each conversion is linear in the cell’s length). O(R · F) space for the coerced records in valid; the error count is O(1). The naive constructor-based version costs the same, so it is rejected for wrong answers, not for speed.

Edge cases to test

  • {"ok": "false"} with {"ok": "bool"} → valid, False. Python’s bool("false") is True, because any non-empty string is truthy.
  • {"ok": "TRUE"} or {"ok": "yes"} → error. Only six exact spellings parse: true, True, 1, false, False and 0.
  • [{"a": "1"}] with {"a": "int", "b": "str"} → {"valid": [], "errors": 1}. A missing field quarantines the whole row, even though a coerces.
  • [{"n": "1", "extra": "z"}] with {"n": "int"} → {"valid": [{"n": 1}], "errors": 0}. Columns outside the schema are dropped, not counted as errors.
  • {"n": "1.0"} as an int → error in both languages: int("1.0") raises, and the TypeScript regex wants digits only. A float-looking value is never silently truncated.
  • {"p": ""} as a float and {"n": " 30 "} as an int: the two references disagree. Python’s float("") raises while JavaScript’s Number("") is 0, and Python’s int(" 30 ") strips the spaces while the TypeScript regex rejects them. Decide whether blank means missing and whether to trim, then test it.

Common mistakes

  • Using bool(value) for booleans — any non-empty string is truthy, so "false" and "0" both become True.
  • Keeping a partially coerced row, or filling the failed field with None, instead of counting the whole row as an error.
  • Counting one error per bad field instead of one per bad row, which double-counts a row with two problems.
  • Leaving everything as strings instead of coercing to the schema’s types.
  • In TypeScript, trusting parseInt alone — it reads a numeric prefix, so "12abc" loads as 12.

Follow-ups an interviewer asks next

Return why each row failed, not just a count.
Collect an error record per bad row: its index, the field, the raw value and a reason such as missing or not an integer, raised as a specific exception from _coerce. The cost stays O(R · F). Write those records to a quarantine file the customer can open, because a count says something is wrong while the list says what to fix in the export.
The headers do not match exactly: Age, a padded age, or customer_age.
Resolve headers once per file, before any row is coerced: strip and lowercase each header, map it through an alias table to a schema field, and report unmapped headers and missing required fields up front. That check is O(columns), not O(rows), and a file missing a required column is rejected with one clear message instead of one error per row.
The export is 10 GB.
Stream it. Python’s csv.DictReader yields one row at a time, so coerce each row and write it to the valid or quarantine output immediately instead of building a list. Memory drops to O(F) for the current row plus counters, and checkpointing the row number lets a crashed run resume instead of restarting.
Add dates, enums and optional fields.
Keep _coerce table-driven and strict. Parse dates with an explicit format, such as datetime.strptime(value, "%Y-%m-%d"), rather than guessing, since 03/04 means different days in different locales. Check enums by set membership. For optional fields, decide per field whether an empty string means missing, and turn it into None only where the schema allows it.

Where this shows up

The customer sends an export that “almost” fits your schema: numbers stored as text, booleans spelled three ways, a few rows missing a column. Coercing at the boundary and quarantining what does not fit is core forward-deployed and integrations work, and the same pattern runs inside every ETL loader and data-validation step. A clear count of rejected rows, traced to the column that caused them, beats a cryptic crash three layers downstream.

Reference solution

Reference solution — Python
def _coerce(value, t):
    if t == "int":
        return int(value)
    if t == "float":
        return float(value)
    if t == "bool":
        if value in ("true", "True", "1"):
            return True
        if value in ("false", "False", "0"):
            return False
        raise ValueError("bad bool")
    return str(value)

def map_rows(rows, schema):
    valid = []
    errors = 0
    for row in rows:
        try:
            record = {}
            for field, t in schema.items():
                if field not in row:
                    raise ValueError("missing")
                record[field] = _coerce(row[field], t)
            valid.append(record)
        except Exception:
            errors += 1
    return {"valid": valid, "errors": errors}


print(map_rows([{"age":"30","name":"al"},{"age":"x","name":"bo"},{"name":"cy"}], {"age":"int","name":"str"}))
# -> {'valid': [{'age': 30, 'name': 'al'}], 'errors': 2}
Reference solution — TypeScript
function coerce(value: string, t: string): any {
  if (t === "int") {
    if (!/^-?\d+$/.test(value)) throw new Error("bad int");
    return parseInt(value, 10);
  }
  if (t === "float") {
    const f = Number(value);
    if (Number.isNaN(f)) throw new Error("bad float");
    return f;
  }
  if (t === "bool") {
    if (value === "true" || value === "True" || value === "1") return true;
    if (value === "false" || value === "False" || value === "0") return false;
    throw new Error("bad bool");
  }
  return String(value);
}

function mapRows(rows: any[], schema: Record<string, string>): { valid: any[]; errors: number } {
  const valid: any[] = [];
  let errors = 0;
  for (const row of rows) {
    try {
      const record: any = {};
      for (const [field, t] of Object.entries(schema)) {
        if (!(field in row)) throw new Error("missing");
        record[field] = coerce(row[field], t);
      }
      valid.push(record);
    } catch {
      errors++;
    }
  }
  return { valid, errors };
}

console.log(JSON.stringify(mapRows([{age:"30",name:"al"},{age:"x",name:"bo"},{name:"cy"}], {age:"int",name:"str"})));
// -> {"valid":[{"age":30,"name":"al"}],"errors":2}

Questions about CSV → Schema Mapper

Why does bool("false") return True in Python?

Because bool tests truthiness, not meaning. Any non-empty string is truthy, so bool("false"), bool("0") and bool("no") all return True, and only the empty string returns False. JavaScript behaves the same way: Boolean("false") is true. To read booleans from a CSV, compare the text against an explicit list of accepted spellings, as this solution does with true, True and 1 for True and false, False and 0 for False, and reject everything else so a typo is caught instead of quietly becoming True.

Should a row with one bad value be dropped or kept with a null?

For a loader like this one, quarantine the whole row. A record with a null where a required value belongs breaks the assumptions of whatever consumes it, from database NOT NULL constraints to averages that silently skip missing values, and the failure surfaces far from its cause. Keeping bad rows out of the valid set, counted and ideally listed with a reason, keeps the loaded data trustworthy and gives the customer something concrete to fix in the source export. Optional fields are the exception, and the schema should say so explicitly.

How do you validate CSV column types in Python?

The csv module returns every cell as a string, so types have to be applied after reading. Write one strict converter per type: int and float already raise ValueError on bad text, and booleans need an allowlist of spellings. Apply the converters per row inside a single try block, so one failure rejects the row and is counted once. For larger schemas, validation libraries such as pydantic let you declare the same rules as a model class and report every failing field, but the underlying idea is the same table of converters.

Why does the TypeScript solution check integers with a regex before parseInt?

Because parseInt reads a leading number and ignores the rest: parseInt("12abc", 10) returns 12 and parseInt("1.5", 10) returns 1, so bad cells would load as plausible numbers. The regex requires the whole string to be an optional minus sign followed by digits. Number is not a safe substitute either, since Number of an empty string is 0 and Number("0x1A") is 26. Python’s int is strict about trailing text but lenient elsewhere: it accepts surrounding spaces, a leading plus sign and underscores between digits.

Finished this one? 0 / 76 Challenges done
NextData Reconciliation Diff Next on the “Integrations & messy data” ladder · rung 8 of 10

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More Challenges