Three records that do not quite agree. Every way of choosing the header is a decision with a cost.
records = [
{'name': 'ada', 'city': 'london'},
{'name': 'grace'}, # no city
{'name': 'linus', 'country': 'fi'}, # a key nobody else has
]
list(records[0]) # ['name', 'city'] - first record wins
keys = {k for r in records for k in r} # {'name', 'city', 'country'}
# -> every column survives, and the order is now a set's order
# The third record has a key the header does not: 'country'
- The first record is a choice, not a shortcut
Taking the header from records[0] gives a stable, predictable column order that matches how the data was built — and it means any key the first record lacks is invisible. The alternative, unioning every key, never loses a column and gives up the order. Both are defensible; picking one and writing it in the contract is the part that matters.
- Missing and extra are not the same problem
A missing key has an obvious, harmless answer: the cell is blank, which is what a table means by 'no value here'. An extra key has no harmless answer — every choice either invents a column or throws data away. That asymmetry is why one is handled with .get and the other with a raise.
- None and empty are the same cell, and 0 is not
str(None) is 'None', four characters that will be read back as the string 'None' by whatever opens the file. An absent value has to become the empty string. But 0 and False are values — str(0) is '0' and that is correct — so the test cannot be `if not value`, which would blank out every zero in the table.
Deal with the empty list first, because everything after it assumes there is a first record. Then take the columns from that record, and build one row per record by walking the columns — not by walking the record.
Walking the header for each record is what keeps the rows aligned and lets a missing key become a blank: record.get(column) returns None whether the key is absent or genuinely None, and both become ''. Check for unexpected keys separately, before or after building the row.
A set makes the extra-key check one comparison: set(record) - set(header), and anything left over is a key the table has no column for. For the cell itself, '' if value is None else str(value) — the `is None` matters, because 0 and False must survive.
def records_to_csv(records): if not records: return ____ header = list(records[____]) known = set(header) rows = [header] for record in records: extra = set(record) - ____ if extra: raise ____(f’no column for {sorted(extra)}') rows.append([ '' if record.____(column) is None else str(record[column]) for column in ____ ]) return rows
def records_to_csv(records):
if not records:
return []
header = list(records[0])
known = set(header)
rows = [header]
for record in records:
extra = set(record) - known
if extra:
raise ValueError(
f"no column for {sorted(extra)} — the header is {header}"
)
rows.append([
"" if record.get(column) is None else str(record[column])
for column in header
])
return rows
for column in header is what guarantees every row has the same length in the same order. Iterating the record instead produces rows that are correct for that record and misaligned with the table — the classic version of this bug puts grace’s name under the city column and nothing raises, because a list of strings is a list of strings.
Subtracting sets says 'what does this record have that the header does not' in one expression, and it names the offending keys for the error message for free. The loop version — checking each key against the header — is the same work, three lines longer, and usually reports only the first offender.
Naming the unexpected key and the header turns a debugging session into a glance: the caller can see immediately whether the record is wrong or the first record was unrepresentative. An error that says 'unexpected key' and nothing else is a message that will be read once and then reproduced under a debugger.
Use csv.DictWriter for anything going to a real file. This function stops at rows of strings, and the genuinely hard part of CSV comes next: a value containing a comma, a quote or a newline has to be quoted and escaped, and hand-rolled joining gets it wrong in ways that corrupt the file rather than crash. DictWriter also takes fieldnames explicitly, which is the right answer whenever the first record cannot be trusted to name the columns, and has extrasaction='raise' — the same decision this exercise makes, already made for you. Write this to understand the shape; ship the standard library.
- Take the header as an optional argument, defaulting to the first record’s keys, and notice how many of the awkward cases become the caller’s problem instead.
- Union every record’s keys for the header instead, and decide what order the extra columns should appear in.
- Check that records_to_csv(csv_to_records(rows)) gives the rows back, and work out which kinds of table survive the round trip and which do not.