Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Data Formats (JSON, YAML, TOML, XML, CSV)  Module 11 – Tooling, reviews and interviews

Data-format questions for interviews and reviews

Answer data-format design questions in seven decisions, work through a catalogue upload in full, and find six data-format bugs in a code review.

  • Intermediate
  • 20 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7, Ruby 4.0.7, sql.js 1.14.2 and Java temurin-25.0.4+101.0.LTS
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

What you will learn

  • Answer a data-format design question in seven decisions, with the trade-off of each stated
  • Choose an upload format for a given set of writers and file sizes, and defend the choice
  • Identify six data-format bugs in code review and name the fix for each
  • Explain a streaming, canonicalisation or validation choice in three sentences

Before you start

An interviewer asks: “Sellers will upload their product catalogues to our marketplace, from 20 rows to 50,000. What format do they upload, and how do we read it?” A weak answer says “CSV” in its first sentence and stops; a strong one takes five minutes, makes seven decisions in order and says what each one costs. In a code review the same knowledge looks smaller: paise = int(float(amount) * 100) turns an amount of 19.99 into 1998 paise, and someone in the team should see it.

You will answer data-format design questions in a fixed order of decisions, follow one complete answer with the check that it designs, find six bugs that data-format reviews catch, each run before and after its fix, and explain a choice about streaming, canonical forms or validation in three sentences.

A strong answer moves from the readers to the tests

Here is how two answers to the catalogue question begin. The first: “CSV, because everyone has Excel.” The second: “Who writes these files, and who reads them? Sellers write them in spreadsheets, so the format must be one that a spreadsheet saves, and the catalogue reads them, so every price and code must arrive exact.” Both may end with CSV, but only the second can say why, which cases would change the choice, and what the platform does with a broken file.

A data-format design question tests that habit: starting from the people and the data, not from a favourite format. A strong answer makes the same seven decisions every time, in an order where each one narrows the next. Step through them on the catalogue question:

Interactive · stage by stage Seven decisions of a design answer, on the catalogue upload Step through the decisions in order. Each step says what the worked answer decides there, and what a weak answer leaves out.
Seven decisions of a design answer, on the catalogue upload, step 8 of 8. Showing Writers and readers, The data model, The format (CSV in UTF-8, JSON Lines too), The schema, Parsing and limits (text first, rules second), Evolution, Tests. In focus: The format.a requirement changesWriters and readerswho, how much, how oftenThe data modelrecords and exact valuesThe formatCSV in UTF-8, JSON Lines tooThe schemathe written contractParsing and limitstext first, rules secondEvolutionthe second versionTestsdamaged files firstimages join the upload

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. Who writes, who reads. Sellers write the file, most of them in a spreadsheet, from 20 rows to 50,000; the catalogue service reads it, and the seller reads the report of what went wrong.
  2. What must stay exact. One row per product: the sku and the barcode as text, prices in paise as integers, the stock as a whole number. Writing this down first rules out every format that cannot keep them.
  3. CSV, and why. CSV in UTF-8, because every spreadsheet saves it and it streams row by row. JSON Lines for sellers whose own systems upload, and spreadsheet files converted at the edge.
  4. The contract. A published list of columns with one rule each: required or not, the pattern, the unit. The upload check and the template that sellers download use the same list.
  5. Read as text, then convert. Decode as UTF-8, with a byte-order mark allowed; read every cell as text and convert it by its column's rule; refuse a file above the size limit before reading it.
  6. The second version. Columns are read by their header names, so a new optional column can be added at the end, and an unknown one is reported and ignored, not fatal.
  7. Damaged files. The tests are uploads that real sellers make: a byte-order mark, grouped amounts such as 1,299.00, a barcode in E notation, a repeated sku and a file of 50,000 rows.
  8. Back to the format. A requirement that changes sends the answer back to the format: if sellers attached images, a zip with a CSV inside would replace the plain CSV. A weak answer names CSV in its first sentence and never comes back.

The order is the point. The writers and readers decide which formats are possible at all; the data model decides which values must survive; only then is a format chosen, and its schema written down. Parsing and limits say what the platform does with the files that real people send, evolution says how the second version arrives, and the tests are made of damaged files, because a design is only as good as its handling of the file nobody expected. The last step also shows what a weak answer misses: a requirement that changes, images attached to each product for example, sends the answer back to the format, and a strong answer names that requirement before the interviewer asks.

Key idea

Answer a data-format question as seven decisions in order, writers and readers, data model, format, schema, parsing and limits, evolution and tests, and say the trade-off of each one aloud.

Checkpoint

Check: The seven decisions of an answer

  1. Question 1 of 2 An interviewer asks which format sellers should upload their catalogues in. What do you settle first?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Who writes the files and who reads them, how many rows and how often

    The seven decisions run from writers and readers to the data model, the format, the schema, parsing and limits, evolution and the tests; each narrows the next.

  2. Question 2 of 2 A candidate answers the catalogue question with: "CSV, because everyone has Excel, and I would validate it." What is the most important thing the answer leaves out?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Which values must stay exact, what happens to a broken file, and what would change the choice

    Both answers may end with CSV. Only the strong one can defend it, handle the file nobody expected, and say which new requirement, such as images per product, would change it.

Worked answer: the format of a catalogue upload

This is the catalogue question answered in full, the way it could be said in five minutes.

Writers and readers. Most sellers keep their catalogue in a spreadsheet and upload it from there; a few large ones upload from their own systems. The catalogue service reads every file, and the seller reads the report of what went wrong, so the report is part of the design. Files run from 20 rows to 50,000, about 5 MB at 100 bytes a row.

Data model. One row per product variant: a sku and a barcode (gtin) that are codes, so text; price and mrp in paise, as integers; stock, a whole number; and a title in any script. A GTIN can “be 8, 12, 13, or 14 digits long”, in the words of GS1 US, so it is checked as digits of those lengths, never as a number.

Format. CSV in UTF-8 for spreadsheet users, because every spreadsheet saves it and it is read one row at a time; JSON Lines: A text format with one JSON value on each line, in UTF-8, every line ended by a line feed; also called JSONL, and close to NDJSON. Each line is parsed on its own, so a file is read one record at a time. More in “JSON Lines, JSONC, JSON5 and other variants” for sellers whose systems upload, because it keeps strings as strings and integers as integers; spreadsheet files converted to rows at the edge, never read deep inside the platform. Answer two questions about your own sellers to see how the table decides:

Interactive · answer and compare Which format should sellers upload? Answer two questions about your sellers. The table marks the formats that fit, and says why the others do not.
Who prepares the files?
How large can one upload be?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Four upload formats for a product catalogue, against what the worked answer needs
Format Sellers can write it Codes and amounts stay exact Read row by row Simple to parse safely
CSV in UTF-8 The file is: rows of text with a header Good every spreadsheet saves it, often with a byte-order mark Mixed all text, so exact with a rule per column, unless the spreadsheet changed codes first Good one record a row, read line by line Good quotes and commas only; nothing to expand or fetch
Spreadsheet file (.xlsx) The file is: a zip of XML parts Good the spreadsheet's own file, with columns that can be set to text Mixed typed cells, but a code typed as a number loses its zeros Poor the cells point into a separate table of strings Mixed XML inside a zip: a safe XML parser and size limits needed
JSON Lines The file is: one JSON object per line Poor spreadsheets cannot save it Good strings stay strings, and amounts are integer paise Good one object a line Good strict JSON, one record at a time
XML with a schema The file is: one document of elements Poor a spreadsheet writes it only through an XML map or export filter set up by hand Good an XSD types every field Mixed with a pull parser, element by element Mixed the DOCTYPE must be refused
How the answers decide
  • “Who prepares the files?” Sellers in a spreadsheet: rules out JSON Lines and XML with a schema and adds a caveat to CSV in UTF-8. A spreadsheet saves CSV and its own file as they are, XML only through a mapping set up by hand, and JSON Lines not at all. CSV still needs each column read as text, since a spreadsheet may change a code first.
  • “Who prepares the files?” Sellers' own systems: adds a caveat to CSV in UTF-8 and Spreadsheet file (.xlsx). A program writes any format, so choose the one that keeps types exact: JSON Lines, with amounts as integer paise.
  • “How large can one upload be?” Up to 50,000 rows: adds a caveat to Spreadsheet file (.xlsx). A spreadsheet file cannot be read a row at a time without its table of strings, so a large upload is converted to rows first.

Schema. The column list with one rule per column, published with a template that sellers download, and the same list in the check that reads the uploads, so the two cannot drift apart.

Parsing and limits. Read the bytes, decode them as UTF-8 with a byte-order mark: The character U+FEFF at the very start of a text: the bytes EF BB BF in UTF-8, FF FE or FE FF in UTF-16. It names the encoding, and many parsers refuse it unless it is removed first. More in “UTF-8, BOMs and line endings in data files” allowed, read every cell as text, and convert each by its column’s rule; a file above the size limit is refused before it is read. Here is an upload with the mistakes that sellers’ spreadsheets really make, and the check that the answer describes:

catalogue.csv: seven products from a seller's spreadsheet CSV · upload/catalogue.csv
sku,title,price,mrp,stock,gtin
0042-RED,Cotton kurta (red),799.00,999.00,12,8901234567890
0042-BLU,Cotton kurta (blue),"1,299.00",1499.00,4,8901234567906
TEA-250,Assam tea 250 g,249.5,260,abc,8901234567913
TEA-250,Assam tea 500 g,449.00,480.00,8,8.90123E+12
LAMP-01,,1999.00,2499.00,3,8901234567937
"DIYA-12","मिट्टी के दीये, 12 का सेट",149.00,199.00,40,8901234567944
MUG-07,Steel mug 300 ml,449.00,399.00,25,8901234567951
The upload check: text first, a rule per column, a report per line Python · upload/validate_upload.py
"""The upload check of the worked answer. A seller's catalogue arrives as CSV in UTF-8, with or without a byte-order
mark; each cell is read as text and converted by its column's rule, so no spreadsheet guess and no float reaches the
catalogue. Good rows go on as JSON Lines, and every problem of a bad row is reported with its line, for the seller.
A file above the size limit is refused before it is read; a column the check does not know is reported and ignored."""
import csv
import io
import json
import os
import re
from decimal import Decimal

COLUMNS = ["sku", "title", "price", "mrp", "stock", "gtin"]
MAX_BYTES = 10_000_000  # twice the largest catalogue: 50,000 rows of about 100 bytes


def paise(column, text):
    if not re.fullmatch(r"[0-9]+(?:\.[0-9]{1,2})?", text):
        separators = ", without separators" if "," in text else ""
        raise ValueError(f'{column} "{text}": write rupees and paise as in 1299.00{separators}')
    return int(Decimal(text) * 100)


def convert(row, line, first_line):
    """The record of one row, and the list of its problems."""
    if None in row or None in row.values():  # DictReader keeps extra cells under None and fills missing ones with None
        return {}, ["one cell for each column of the header, no more and no fewer"]
    problems, record = [], {"sku": row["sku"], "title": row["title"].strip()}
    if not re.fullmatch(r"[A-Z0-9][A-Z0-9-]{0,39}", row["sku"]):
        problems.append(f'sku "{row["sku"]}": capital letters, digits and hyphens only')
    elif first_line.setdefault(row["sku"], line) != line:
        problems.append(f"sku: also on line {first_line[row['sku']]}")
    if not record["title"]:
        problems.append("title: empty")
    for column in ("price", "mrp"):
        try:
            record[f"{column}_paise"] = paise(column, row[column])
        except ValueError as err:
            problems.append(str(err))
    if re.fullmatch(r"[0-9]+", row["stock"]):
        record["stock"] = int(row["stock"])
    else:
        problems.append(f'stock "{row["stock"]}": a whole number of 0 or more')
    if re.fullmatch(r"[0-9]{8}|[0-9]{12,14}", row["gtin"]):
        record["gtin"] = row["gtin"]  # a code, kept as text: never a number
    else:
        problems.append(f'gtin "{row["gtin"]}": 8, 12, 13 or 14 digits; format the column as text')
    if record.get("price_paise", 0) > record.get("mrp_paise", float("inf")):
        problems.append(f"price {row['price']} is above the mrp, {row['mrp']}")
    return record, problems


if os.path.getsize("catalogue.csv") > MAX_BYTES:
    raise SystemExit(f"catalogue.csv refused: more than {MAX_BYTES:,} bytes")
with open("catalogue.csv", "rb") as f:
    data = f.read()
text = data.decode("utf-8-sig")  # a byte-order mark is allowed and removed; bytes that are not UTF-8 refuse the file
reader = csv.DictReader(io.StringIO(text, newline=""))
if missing := [c for c in COLUMNS if c not in reader.fieldnames]:
    raise SystemExit(f"catalogue.csv refused: no column {', '.join(missing)}")
mark = "with" if data.startswith(b"\xef\xbb\xbf") else "without"
print(f"catalogue.csv: UTF-8 {mark} a byte-order mark; columns {', '.join(reader.fieldnames)}")
for column in reader.fieldnames:
    if column not in COLUMNS:
        print(f"column {column}: not one of {', '.join(COLUMNS)}, so it is ignored")

accepted, first_line, rows = [], {}, 0
for row in reader:
    rows += 1
    record, problems = convert(row, reader.line_num, first_line)
    for problem in problems:
        print(f"line {reader.line_num} ({row['sku']}): {problem}")
    if not problems:
        accepted.append(record)
print(f"{len(accepted)} of {rows} rows accepted, as JSON Lines:")
for record in accepted:
    print(json.dumps(record, ensure_ascii=False))

Output

catalogue.csv: UTF-8 with a byte-order mark; columns sku, title, price, mrp, stock, gtin
line 3 (0042-BLU): price "1,299.00": write rupees and paise as in 1299.00, without separators
line 4 (TEA-250): stock "abc": a whole number of 0 or more
line 5 (TEA-250): sku: also on line 4
line 5 (TEA-250): gtin "8.90123E+12": 8, 12, 13 or 14 digits; format the column as text
line 6 (LAMP-01): title: empty
line 8 (MUG-07): price 449.00 is above the mrp, 399.00
2 of 7 rows accepted, as JSON Lines:
{"sku": "0042-RED", "title": "Cotton kurta (red)", "price_paise": 79900, "mrp_paise": 99900, "stock": 12, "gtin": "8901234567890"}
{"sku": "DIYA-12", "title": "मिट्टी के दीये, 12 का सेट", "price_paise": 14900, "mrp_paise": 19900, "stock": 40, "gtin": "8901234567944"}

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 validate_upload.py

The check accepted 2 of the 7 rows and reported six problems, each with its line, in words the seller can act on. The byte-order mark was removed by decoding with utf-8-sig, so it did not become part of the first column’s name, the KeyError of the encodings lesson. The barcode in E notation is a code that a spreadsheet had already turned into a number, as the messy CSV lesson showed, and only a rule per column notices it. The Devanagari title passed unchanged.

CSV to JSON Converter Convert a seller's CSV file to JSON with number conversion off to see every cell as text; with it on, codes such as 007 still keep their leading zeros.

Amounts and prices from Indian sellers

Sellers’ spreadsheets often format amounts with digit grouping, 1,299.00, or in the Indian way, 1,29,900.00 for 1.299 lakh. The check refuses both and says what to write, rather than guessing which grouping a seller meant. The mrp column holds the maximum retail price, and the check refuses a selling price above it as a mistake, since one of the two columns must be wrong.

Evolution. Columns are read by their header names, so a new optional column goes at the end without breaking anyone, and a column the check does not know is reported and ignored.

Tests. Fixture uploads like the one above, plus a file of 50,000 rows for the limit and the same file uploaded twice, which must change nothing: an idempotent import: An import that gives the same result however many times the same file is loaded, because each record is matched by its key and replaced rather than added again. It makes a retry after a failure safe. matches each row by its sku and replaces the product rather than adding it again.

Checkpoint

Check: Choosing and reading an upload format

  1. Question 1 of 2 Most sellers keep their catalogue in a spreadsheet. Why does the worked answer not ask them for JSON Lines?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Spreadsheets cannot save JSON Lines, so the sellers could not write it

    JSON Lines stays in the answer for sellers whose own systems upload, where a program writes the file and exact types matter more than a spreadsheet.

  2. Question 2 of 2 The upload check accepted 2 of 7 rows and reported 6 problems by line instead of refusing the whole file. Why is that the right design here?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Each row is a separate product, so good rows can go on while the seller fixes the reported lines

    Structural problems refuse the file; problems of one row refuse that row and say why, so that the next upload of the same file is the fix.

Three parser settings that a review should question

In a review of code that reads data, the few lines that set up the parser decide more than all the code after them. Each of these three passed its author’s tests, because the tests used files without the case that breaks them.

A YAML 1.1 parser in a new tool

A new deployment tool reads its settings with YAML.safe_load, which in Ruby is Psych, a parser that follows YAML 1.1:

A new tool's settings, read by Psych and checked after loading

YAML · review/tool.yaml

# Settings of a new deployment tool, written by hand
target: staging
dry_run: no
shift_window: 22:30
file_mode: 0640
release: "2.10"

Ruby · review/yaml_tool.rb

# Under review: a new deployment tool loads its settings with YAML.safe_load, that is with Psych, which follows YAML
# 1.1. Two settings come back as numbers, without an error. The fix checks each setting's type as soon as the file is
# loaded, so that a wrong type stops the tool with the key's name, whatever parser read the file.
require "yaml"

EXPECTED = { "target" => [String], "dry_run" => [TrueClass, FalseClass], "shift_window" => [String],
             "file_mode" => [String], "release" => [String] }.freeze

settings = YAML.safe_load(File.read("tool.yaml"))
puts "as reviewed: #{settings.inspect}"

problems = EXPECTED.filter_map do |key, types|
  value = settings[key]
  "#{key}: got the #{value.class} #{value.inspect}, needs a #{types.join(' or ')}" unless types.any? { |t| value.is_a?(t) }
end
puts "fixed, the check after loading:", problems.map { |p| "  #{p}" }

Output

as reviewed: {"target" => "staging", "dry_run" => false, "shift_window" => 81000, "file_mode" => 416, "release" => "2.10"}
fixed, the check after loading:
  shift_window: got the Integer 81000, needs a String
  file_mode: got the Integer 416, needs a String

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby yaml_tool.rb

shift_window: 22:30 came back as the number 81000 and file_mode: 0640 as 416, without an error; a YAML 1.2 parser would have read a string and the number 640. The review comment: a new tool should use a parser that follows the YAML 1.2 core schema, and check each setting’s type right after loading whatever the parser, as the fixed half does; the values that must stay text, such as times and file modes, go in quotes, and dry_run: no becomes dry_run: false, which both versions read as a boolean.

DOCTYPE processing left on

An upload handler parses partners’ XML invoices with DocumentBuilderFactory.newInstance() and nothing else. The invoice below declares one harmless entity in its DOCTYPE, and the check fails on the parser as reviewed and passes on the fixed one:

The same invoice, parsed as reviewed and as fixed

XML · review/invoice.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE invoice [
  <!ENTITY seller "MySmartCoPilot Books">
]>
<invoice><seller>&seller;</seller><amount_paise>124900</amount_paise></invoice>

Java · review/ReviewXml.java

// Under review: an upload handler parses partners' XML invoices with DocumentBuilderFactory.newInstance() and no
// other setting. The check below fails on that parser, which processes the document's DOCTYPE, and passes on the fix,
// which refuses any DOCTYPE before a single entity is declared.
import java.io.File;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import org.xml.sax.SAXParseException;
import org.xml.sax.helpers.DefaultHandler;

public class ReviewXml {
    static DocumentBuilderFactory asReviewed() {
        return DocumentBuilderFactory.newInstance();
    }

    static DocumentBuilderFactory fixed() throws Exception {
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
        factory.setXIncludeAware(false);
        return factory;
    }

    /** The check for the test suite: true when the factory refuses a document that has a DOCTYPE. */
    static boolean refusesDoctype(DocumentBuilderFactory factory, File file) throws Exception {
        DocumentBuilder builder = factory.newDocumentBuilder();
        builder.setErrorHandler(new DefaultHandler()); // throw on a fatal error, print nothing of its own
        try {
            String seller = builder.parse(file).getElementsByTagName("seller").item(0).getTextContent();
            System.out.println("  parsed, seller = " + seller);
            return false;
        } catch (SAXParseException e) {
            System.out.println("  refused at line " + e.getLineNumber() + ": " + e.getMessage());
            return true;
        }
    }

    public static void main(String[] args) throws Exception {
        File invoice = new File("invoice.xml");
        System.out.println("as reviewed:");
        System.out.println("  refuses a DOCTYPE: " + refusesDoctype(asReviewed(), invoice));
        System.out.println("fixed:");
        System.out.println("  refuses a DOCTYPE: " + refusesDoctype(fixed(), invoice));
    }
}

Output

as reviewed:
  parsed, seller = MySmartCoPilot Books
  refuses a DOCTYPE: false
fixed:
  refused at line 2: DOCTYPE is disallowed when the feature "http://apache.org/xml/features/disallow-doctype-decl" set to true.
  refuses a DOCTYPE: true

Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java ReviewXml.java

The parser as reviewed processed the DOCTYPE and expanded the entity into the seller’s name; a parser that follows a DOCTYPE can be made to expand entities without end or, with external entities, to read files on the server, as the safe XML parsing lesson showed. The fix is the one the OWASP cheat sheet gives for DOM parsers, to reject DOCTYPE declarations: disallow-doctype-decl made the parser refuse the invoice at line 2. Python’s own XML documentation notes that its Expat parser “does not access local files or create network connections” by default, so the same review in Python looks first at entity expansion and the Expat version.

CSV opened without newline=“”

An export job writes CSV with open(name, "w"), and the import reads it with open(name):

A note with a line break, written and read without and with newline Python · review/csv_newline.py
"""Under review: an export job writes CSV with open(name, "w") and an import reads it with open(name). A note with a
line break inside quotes loses its \r on the way in, and on Windows each row gains an extra \r on the way out. Both
are fixed by newline="" when the file is opened, as the csv module's documentation asks."""
import csv

ROWS = [["id", "note"], ["BK-20417", "Leave at the gate\r\nCall on arrival"]]

# Writing: in text mode Windows turns every "\n" into "\r\n"; newline="\r\n" does the same on any computer.
with open("as_reviewed.csv", "w", newline="\r\n", encoding="utf-8") as f:
    csv.writer(f).writerows(ROWS)
with open("fixed.csv", "w", newline="", encoding="utf-8") as f:
    csv.writer(f).writerows(ROWS)
for name in ("as_reviewed.csv", "fixed.csv"):
    with open(name, "rb") as f:
        print(f"{name:15} {f.read()!r}")

# Reading: without newline="", the "\r\n" inside the quoted note comes back as "\n".
with open("fixed.csv", encoding="utf-8") as f:
    print("read as reviewed:", repr(list(csv.reader(f))[1][1]))
with open("fixed.csv", newline="", encoding="utf-8") as f:
    print("read fixed:      ", repr(list(csv.reader(f))[1][1]))

Output

as_reviewed.csv b'id,note\r\r\nBK-20417,"Leave at the gate\r\r\nCall on arrival"\r\r\n'
fixed.csv       b'id,note\r\nBK-20417,"Leave at the gate\r\nCall on arrival"\r\n'
read as reviewed: 'Leave at the gate\nCall on arrival'
read fixed:       'Leave at the gate\r\nCall on arrival'

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 csv_newline.py

Written the way Windows opens text files, every line ended in \r\r\n, inside the quoted note too, and read back without newline="" the note’s \r\n came back as \n. The csv module’s documentation warns of both: without that argument, line breaks inside quoted fields “will not be interpreted correctly”, and writing on a system whose lines end in \r\n adds a second \r. The fix is one argument on both open calls.

Checkpoint

Check: Parser settings in a review

  1. Question 1 of 2 Which line made the fixed Java parser refuse the invoice?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)

    The reviewed parser expanded the entity into the seller's name; the fixed one refused the DOCTYPE with "DOCTYPE is disallowed", which is the check a test suite can keep.

  2. Question 2 of 2 What does the csv module's documentation say goes wrong when a file is opened without newline=""?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Line breaks inside quoted fields are misread, and on platforms with \r\n endings an extra \r is written

    The fix is newline="" on every open() of a csv file, for reading and for writing, because the csv module does its own newline handling.

Three contracts that break between a writer and a reader

The other three bugs appear only where one program’s output meets another program’s reading: a float between a text amount and a ledger, a schema between two versions of an object, a serialiser between a signer and a verifier.

Floats for money

The importer turns each amount into paise with int(float(amount) * 100):

Rupees to paise, through a float and through Decimal Python · review/money.py
"""Under review: an importer turns each row's rupee amount into paise with int(float(amount) * 100). The fix reads the
text as a Decimal, which holds 19.99 exactly, and refuses an amount with more than two decimal places."""
from decimal import Decimal


def to_paise_as_reviewed(amount):
    return int(float(amount) * 100)


def to_paise_fixed(amount):
    value = Decimal(amount)
    if value != value.quantize(Decimal("0.01")):
        raise ValueError(f"{amount} has more than two decimal places")
    return int(value * 100)


for amount in ["19.99", "0.29", "1.15", "100.00"]:
    print(f"{amount:>6}: as reviewed {to_paise_as_reviewed(amount):>5} paise, fixed {to_paise_fixed(amount):>5}")

texts = [f"{paise // 100}.{paise % 100:02d}" for paise in range(10_000)]
lost = [text for paise, text in enumerate(texts) if to_paise_as_reviewed(text) != paise]
print(f"from 0.00 to 99.99, {len(lost)} of 10,000 amounts lose a paisa as reviewed, {lost[0]} first")
print(f"fixed, every one is exact: {all(to_paise_fixed(text) == paise for paise, text in enumerate(texts))}")
try:
    to_paise_fixed("19.999")
except ValueError as err:
    print("fixed, on 19.999:", err)

Output

 19.99: as reviewed  1998 paise, fixed  1999
  0.29: as reviewed    28 paise, fixed    29
  1.15: as reviewed   114 paise, fixed   115
100.00: as reviewed 10000 paise, fixed 10000
from 0.00 to 99.99, 573 of 10,000 amounts lose a paisa as reviewed, 0.29 first
fixed, every one is exact: True
fixed, on 19.999: 19.999 has more than two decimal places

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 money.py

19.99 became 1998 paise and 0.29 became 28: a binary float holds 19.99 as a value a little below it, and int() cuts the rest away. Across the 10,000 amounts from 0.00 to 99.99, 573 lose a paisa that way, and no error appears anywhere. Python’s decimal documentation puts the difference plainly: “Decimal numbers can be represented exactly”, and built from a string they keep the digits as written. The fixed version reads the text as a Decimal, and refuses an amount with a third decimal place rather than rounding it in silence.

A closed schema next to allOf

A delivery address reuses the base address through allOf and adds "additionalProperties": false to refuse misspelt members:

A delivery address checked with additionalProperties and with unevaluatedProperties Python · review/closed_schema.py
"""Under review: a delivery address reuses the base address schema through allOf and closes itself with
additionalProperties: false, to refuse misspelt members. A small evaluator of the five keywords involved shows a
correct address refused; unevaluatedProperties, which looks through allOf, accepts it and still refuses the typo."""

BASE = {"properties": {"city": {}, "pin": {}}, "required": ["city", "pin"]}
AS_REVIEWED = {"allOf": [BASE], "properties": {"landmark": {}}, "additionalProperties": False}
FIXED = {"allOf": [BASE], "properties": {"landmark": {}}, "unevaluatedProperties": False}


def evaluate(schema, value):
    """(errors, members evaluated) for properties, required, allOf, additionalProperties, unevaluatedProperties."""
    errors = [f"{name}: required" for name in schema.get("required", []) if name not in value]
    evaluated = set(schema.get("properties", {})) & set(value)
    for subschema in schema.get("allOf", []):
        sub_errors, sub_evaluated = evaluate(subschema, value)
        errors += sub_errors
        if not sub_errors:  # only a subschema that passed counts as having evaluated its members
            evaluated |= sub_evaluated
    if schema.get("additionalProperties") is False:  # sees the properties beside it, never those inside allOf
        errors += [f"{name}: not allowed" for name in value if name not in schema.get("properties", {})]
    if schema.get("unevaluatedProperties") is False:  # runs last, with everything evaluated at this place
        errors += [f"{name}: not allowed" for name in value if name not in evaluated]
    return errors, evaluated


addresses = {
    "a correct address": {"city": "Pune", "pin": "411001", "landmark": "near the bus stand"},
    "a misspelt member": {"city": "Pune", "pin": "411001", "landmrk": "near the bus stand"},
}
for label, schema in [("as reviewed", AS_REVIEWED), ("fixed", FIXED)]:
    for what, address in addresses.items():
        errors, _ = evaluate(schema, address)
        print(f"{label:11} {what}: {'; '.join(errors) or 'valid'}")

Output

as reviewed a correct address: city: not allowed; pin: not allowed
as reviewed a misspelt member: city: not allowed; pin: not allowed; landmrk: not allowed
fixed       a correct address: valid
fixed       a misspelt member: landmrk: not allowed

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 closed_schema.py

The correct address was refused: additionalProperties judges members only against the properties beside it, so city and pin, named inside allOf, are extra to it. unevaluatedProperties, from JSON Schema 2019-09 on, counts every member that any keyword at the same place evaluated, so it accepted the address and still caught landmrk, as the objects and arrays lesson works through in detail.

sort_keys used as canonical JSON

A webhook receiver checks a partner’s signature against json.dumps of the parsed body with sorted keys, believing that this rebuilds the text the partner signed. The partner’s service is written in JavaScript:

One receipt, sorted by Python and by JavaScript

JSON · review/receipt.json

{"receipt": "R-77", "note": "Chai café, Pune", "tax_rate": 5.0, "amount_paise": 10500}

Python · review/sign_sorted.py

"""Under review: a webhook receiver checks a partner's signature against json.dumps(..., sort_keys=True) of the
parsed body, as if that were the text the partner signed. The partner's JavaScript sorts the same members, and the
two texts still differ. The fix checks the signature over the bytes that arrived, before anything is parsed."""
import hashlib
import json

with open("receipt.json", "rb") as f:
    body = f.read()  # the bytes as they arrived

text = json.dumps(json.loads(body), sort_keys=True, separators=(",", ":"))
print("Python's sorted text:", text)
print("its SHA-256 begins       ", hashlib.sha256(text.encode()).hexdigest()[:16])
print("the body's SHA-256 begins", hashlib.sha256(body).hexdigest()[:16])

Output

Python's sorted text: {"amount_paise":10500,"note":"Chai caf\u00e9, Pune","receipt":"R-77","tax_rate":5.0}
its SHA-256 begins        da590fca9092c3f7
the body's SHA-256 begins 206ed9acbd5db7de

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sign_sorted.py

JavaScript · review/sorted_stringify.mjs

// The partner's side: the same receipt, parsed and written again with its members sorted by name.
const body = '{"receipt": "R-77", "note": "Chai café, Pune", "tax_rate": 5.0, "amount_paise": 10500}';

const receipt = JSON.parse(body);
console.log("JavaScript's sorted text:", JSON.stringify(receipt, Object.keys(receipt).sort()));

Output

JavaScript's sorted text: {"amount_paise":10500,"note":"Chai café, Pune","receipt":"R-77","tax_rate":5}

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node sorted_stringify.mjs

Both sorted the same members, and the texts still differ: Python wrote caf\u00e9 and 5.0, JavaScript café and 5, so the two hashes would never match, and neither matches the bytes that arrived. Sorting keys is not a canonical form: The one spelling that a rule picks out of all the texts holding the same data, so that equal data always gives equal bytes, which is what a hash or a signature needs. More in “Canonical JSON (RFC 8785) and signatures”; RFC 8785 also fixes how strings and numbers are written. The fix is to check the signature over the exact bytes received, before parsing, and to use RFC 8785 on both sides only where the text must be rebuilt, as the canonical JSON lesson does.

Checkpoint

Check: Contracts between writer and reader

  1. Question 1 of 2 How many paise does int(float("19.99") * 100) give?

    Type a number.

    A hint

    The float nearest to 19.99 is a little below it, and int() cuts off what follows the point.

    Show the answer: Question 1 of 2

    Answer: 1998 paise

    19.99 times 100 in binary floating point is 1998.9999999999998, and int() gives 1998. Decimal("19.99") * 100 is exactly 1999.

  2. Question 2 of 2 Python and JavaScript both sorted the receipt's members. Why did their texts still differ?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Python escaped é as \u00e9 and wrote 5.0, where JavaScript kept é and wrote 5

    A canonical form fixes order, strings and numbers together, as RFC 8785 does. A signature is safest over the exact bytes that arrived.

Explain each choice in three sentences: when, how, the trap

Interviews and design reviews often ask for one decision at a time, briefly: “Would you stream this?” A good short answer has three sentences: when the choice is right, how it is done, and the trap, with a number where the track has measured one.

Choice When How The trap
Streaming the file can outgrow memory, or arrives without end JSON Lines or CSV a line at a time, iterparse for XML, an incremental parser for one large array stopping the whole run at the first bad record, instead of reporting its line and going on
Canonical form bytes are hashed, signed or compared RFC 8785 for JSON, Canonical XML for XML, or the exact bytes as received sort_keys is not canonical: the receipt above came out two ways
Validation data crosses a boundary: an upload, a message, a configuration file a schema on the parsed data, JSON Schema 2020-12 or an XSD, with every error reported a closed schema refuses new versions, and an unknown keyword checks nothing

For example, on streaming: “I would stream it when the file can outgrow memory, which a whole-document parser needs several times over: the streaming lesson measured 76 MB of memory for a 13 MB JSON array in CPython. I would read it as JSON Lines, one record at a time, and keep only running totals. The trap is a bad record in the middle, so I would report it with its line number and go on, rather than lose the rest of the night’s records.”

Checkpoint

Interview round: choose the stronger answer

  1. Question 1 of 2 Choose the stronger answer to "Would you stream this 6 GB export?"

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: "Yes: a whole-document parser needs several times the file in memory. I would read it as JSON Lines, a record at a time. A bad record gets reported with its line, and the run goes on."

    When the file can outgrow memory, how it is read a record at a time, and the trap of stopping at the first bad record: three sentences that an interviewer can follow up.

  2. Question 2 of 2 Choose the stronger answer to "How do the signer and the verifier agree on what was signed?"

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: "They sign and check the exact bytes sent. Where a text must be rebuilt, both use RFC 8785. Sorted keys alone are not enough: Python and JavaScript wrote the same receipt two ways."

    A signature covers bytes, so the bytes must be the same on both sides: the exact bytes received, or one canonical form written by both.

Practise: review and fix an importer

The exercise is the review itself: a short importer with three of this lesson’s bugs, an identifier read as a number, an amount through a float and a CSV text cut into lines before the csv module sees the quotes. The sample tests use the cases of this lesson, from 19.99 to a note with a line break:

Exercise · Medium · Python

Fix the three data-format bugs of an importer

The starter is a pull request to review: load_rows(csv_text) reads a bank statement exported as CSV and returns its rows as records {"ref": …, "amount_paise": …, "note": …}. It has three data-format bugs. Find them and fix them, so that:

  • ref stays text, exactly as written: the reference 007 stays "007";
  • amount_paise is exact: "19.99" gives 1999, "0.29" gives 29, and an amount with more than two decimal places, such as "19.999", raises ValueError;
  • a note in quotes keeps its line break as it is in the file, \r\n included.

The first line of the text is always the header ref,amount,note, and a statement with no rows gives [].

Starter code · importer.py

import csv


def load_rows(csv_text):
    """The rows of a bank statement as records. Under review: three data-format bugs; the tests show them."""
    rows = list(csv.reader(csv_text.splitlines()))
    records = []
    for ref, amount, note in rows[1:]:
        records.append({"ref": int(ref), "amount_paise": int(float(amount) * 100), "note": note})
    return records
The sample tests · test_importer.py
from importer import load_rows

HEADER = "ref,amount,note\r\n"


def test_reference_stays_text():
    """a reference is a code: its leading zeros stay"""
    assert load_rows(HEADER + "007,10.00,ok\r\n") == [{"ref": "007", "amount_paise": 1000, "note": "ok"}]


def test_amounts_are_exact():
    """19.99 is 1999 paise, not 1998"""
    rows = load_rows(HEADER + "101,19.99,x\r\n102,0.29,y\r\n103,100,z\r\n")
    assert [row["amount_paise"] for row in rows] == [1999, 29, 10000]


def test_more_than_two_decimals_is_refused():
    """an amount with a third decimal place is an error, not a rounding"""
    try:
        load_rows(HEADER + "104,19.999,x\r\n")
    except ValueError:
        return
    assert False, "19.999 should raise ValueError"


def test_quoted_line_break_survives():
    """a line break inside quotes is part of the note, exactly as written"""
    rows = load_rows(HEADER + '105,1.00,"Leave at the gate\r\nCall first"\r\n')
    assert rows[0]["note"] == "Leave at the gate\r\nCall first"


def test_no_rows():
    """a statement with only its header has no records"""
    assert load_rows(HEADER) == []
A hint

str.splitlines() cuts the text at every line break, also inside quotes, before the csv module can see the quotes: give csv.reader an io.StringIO(csv_text, newline="") instead. Then read the amount with decimal.Decimal, and compare it with value.quantize(Decimal("0.01")) to find more than two decimal places.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Finally, a self-check across the five parts of this lesson, the seven decisions, the worked upload, the six bugs and the short explanations, which begins with the part where your answers have been weakest:

Practice that adapts to you

Practise this lesson

14 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 14 In the seven decisions, which one comes right after the writers and readers?

    Choose one answer.

    Show the answer: Question 1 of 14

    Answer: The data model

    Writers and readers, data model, format, schema, parsing and limits, evolution, tests.

  2. Question 2 of 14 After your catalogue answer, the interviewer adds: sellers will now attach up to ten images to each product. Which decision do you revisit first?

    Choose one answer.

    Show the answer: Question 2 of 14

    Answer: The format, since a plain CSV cannot carry images; a zip with the CSV and the images inside is one answer

    The last step of the flow sends the answer back to the format; naming that requirement before being asked is what separates a strong answer.

  3. Question 3 of 14 How does the upload check treat the gtin column?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: As text of 8, 12, 13 or 14 digits, never as a number

    Codes are text with a pattern; amounts are numbers with a rule. Keeping the two apart is what catches 8.90123E+12.

  4. Question 4 of 14 Why does the upload check decode the file with utf-8-sig rather than utf-8?

    Choose one answer.

    Show the answer: Question 4 of 14

    Answer: A byte-order mark is allowed and removed, so the first column is named sku and not \ufeffsku

    Spreadsheet exports often begin with a byte-order mark; accepting it on purpose keeps the column names right.

  5. Question 5 of 14 A seller's upload times out, and the seller uploads the same file again. What should the platform's catalogue look like after the second upload?

    Choose one answer.

    Show the answer: Question 5 of 14

    Answer: The same as after one upload, because each row is matched by its sku and replaced

    Matching by a key and replacing makes the result depend on the file, not on how many times it arrived.

  6. Question 6 of 14 Psych read file_mode 0640 from tool.yaml. What number did it build?

    Type a number.

    Show the answer: Question 6 of 14

    Answer: 416

    Under YAML 1.1 a leading 0 means octal, and 640 in base 8 is 6 × 64 + 4 × 8 = 416. A YAML 1.2 core-schema parser reads 640.

  7. Question 7 of 14 The same invoice handler is written in Python with xml.etree.ElementTree. What does the lesson say a review looks at first?

    Choose one answer.

    Show the answer: Question 7 of 14

    Answer: Entity expansion and the Expat version, since Python's documentation says Expat does not access local files by default

    Defaults differ between parsers, so a review asks what this parser does by default, as the Java and Python documentation each describe.

  8. Question 8 of 14 Which call opens a CSV file for csv.writer correctly?

    Choose one answer.

    Show the answer: Question 8 of 14

    Answer: open(name, "w", newline="", encoding="utf-8")

    The documentation says it should always be safe to give newline="" for csv files, since the module handles newlines itself.

  9. Question 9 of 14 Why does Decimal("19.99") keep the amount exact when float("19.99") does not?

    Choose one answer.

    Show the answer: Question 9 of 14

    Answer: Decimal numbers are represented exactly, and one built from a string keeps the digits as written

    Build amounts from their text with Decimal, or keep them as integers in minor units, and never pass them through a float.

  10. Question 10 of 14 Of the 10,000 amounts from 0.00 to 99.99, how many lost a paisa through int(float(amount) * 100)?

    Type a number.

    Show the answer: Question 10 of 14

    Answer: 573 amounts

    money.py tried every amount and counted 573 that gave one paisa less, 0.29 the first; with Decimal, every one was exact.

  11. Question 11 of 14 Which keyword closes a schema built with allOf without refusing the members named inside allOf?

    Choose one answer.

    Show the answer: Question 11 of 14

    Answer: "unevaluatedProperties": false

    The fixed schema accepted the correct address and still refused landmrk; the reviewed one refused both.

  12. Question 12 of 14 A partner signs each webhook body with an HMAC over the bytes it sends. Where should your service check it?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: Over the raw body bytes, before parsing them

    Check the signature first, on what arrived; parse only what passed.

  13. Question 13 of 14 Which three parts make a short explanation of a data-format choice?

    Choose one answer.

    Show the answer: Question 13 of 14

    Answer: When the choice is right, how it is done, and the trap

    For streaming: when the file can outgrow memory, read a record at a time, and report a bad record without stopping.

  14. Question 14 of 14 In the lesson's table, which trap belongs to validation?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: A closed schema refuses new versions, and an unknown keyword checks nothing

    Validation belongs at the boundary, on the parsed data, with every error reported; its traps are schemas that are too strict to evolve and keywords that silently do nothing.

Interview questions

Warm-up (fresher to 2–5 years): what shape should an answer to a data-format design question take?

Seven decisions in order, each with its reason. Start with the writers and readers: who produces the data, who consumes it, how much and how often. Then the data model: the records, their keys and the values that must stay exact, such as amounts in minor units and times with offsets. Then the format, chosen against those needs with the trade-off said out loud, and the schema that states the contract. Then parsing and limits: the encoding, the parser’s settings, the sizes, and what happens to a bad record. Then evolution, how a second version arrives without breaking readers, and last the tests, built from damaged files. A weak answer names a format in its first sentence and stops; a strong one also says which requirement would change the choice.

Three harder questions follow: a proposal to accept YAML in a public API, an audit log that must be verifiable for years, and a review that must fit into fifteen minutes. Make your seven decisions first, then compare them with the first steps given:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A teammate proposes that a new public API accept request bodies in YAML as well as JSON, because partners find YAML easier to write by hand. How do you respond, and what would you offer the partners instead?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Senior to staff

    Design the record format of an audit log of payment approvals that someone must be able to verify years later, without the code that wrote it. What do you store, in which format, and how is it checked?

    Model answer, Pro. First step and follow-ups

  3. Mid to senior

    You have fifteen minutes to review a 300-line pull request that adds a nightly import of a partner's files. Which data-format risks do you look for first, in which order, and which tests do you ask for?

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What are the seven decisions of a data-format design answer?

    Writers and readers, the data model, the format, the schema, parsing and limits, evolution, and tests, in that order, with the trade-off of each said aloud.

  2. What separates a strong data-format design answer from a weak one?

    A weak answer names a format in its first sentence and stops. A strong one works through the decisions and says which requirement would change the choice.

  3. What did the worked answer choose for sellers uploading a catalogue from spreadsheets?

    CSV in UTF-8, every cell read as text with a rule per column. It accepted 2 of the 7 sample rows and reported each problem of the other five with its line.

  4. Which three parser settings deserve a question in every code review?

    A YAML 1.1 loader in new code, DOCTYPE processing left on, and CSV files opened without newline="".

  5. Which three contracts break between a writer and a reader?

    Amounts sent as floats, which lost a paisa on 573 of 10,000 amounts; a closed schema next to allOf; and sorted keys used as a canonical form.

  6. How do you explain a streaming, canonical-form or validation choice in brief?

    In three sentences: when it applies, how you do it, and the trap, with a measured number where there is one.

Key takeaways

  • A data-format design answer is seven decisions in order, from the writers and readers to the tests, each with its trade-off, and it names the requirement that would change the format.
  • For sellers with spreadsheets the worked answer chose CSV in UTF-8, read every cell as text with a rule per column, accepted 2 of the 7 sample rows and reported each problem of the other five with its line.
  • Three parser settings deserve a question in every review: a YAML 1.1 loader in new code, a DOCTYPE left on, and csv files opened without newline="".
  • Three contracts break between writer and reader: amounts through floats, which lost a paisa on 573 of 10,000 amounts, a closed schema next to allOf, and sorted keys used as a canonical form.
  • A short explanation of streaming, canonicalisation or validation says when, how and the trap, with a measured number where there is one.

Terms in this lesson

idempotent import
An import that gives the same result however many times the same file is loaded, because each record is matched by its key and replaced rather than added again. It makes a retry after a failure safe.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.