The tool that does the work, and the exact reason it cannot be trusted on its own here. Read the third line.
header = ['name', 'role', 'city']
row = ['ada', 'engineer', 'london']
print(dict(zip(header, row)))
# -> {'name': 'ada', 'role': 'engineer', 'city': 'london'}
short = ['ada', 'engineer']
print(dict(zip(header, short)))
# -> {'name': 'ada', 'role': 'engineer'} city vanished, no error
print(dict(zip(header, short, strict=True))) # ValueError (Python 3.10+)
- zip stops at the shortest, silently
Given a header of three and a row of two, zip produces two pairs and says nothing. The missing column simply is not in the dictionary, so the failure shows up later as a KeyError somewhere unrelated — or worse, as a report with a blank column nobody questions.
- dict(zip(...)) is the idiom for 'pair these up'
zip yields tuples and dict() accepts an iterable of pairs, so the two compose into one readable expression. It is worth recognising on sight, because you will meet it far more often than you write it.
- strict=True exists, and checking yourself is still better here
Python 3.10 added strict=True, which raises when the lengths differ. It is the right tool when any mismatch is a programming error. Here the caller needs to know *which* row and *how* it differed, so an explicit check produces a far more useful message than the built-in one.
The first row is different from all the others. Separate it out first, then the rest of the function is the same operation repeated — and one check before each repetition.
dict(zip(header, row)) builds one record. The check you need is that len(row) equals len(header), because zip will not tell you when it does not.
Return [] when rows is empty. Take header = rows[0] and loop over rows[1:] with enumerate so the error message can name the row. Compare the lengths, raise ValueError with both counts if they differ, otherwise append dict(zip(header, row)).
def csv_to_records(rows): if not rows: return [] header = rows[____] records = [] for number, row in enumerate(rows[____:], start=2): if len(row) != len(____): raise ValueError(...) records.append(dict(____(header, row))) return records
def csv_to_records(rows):
if not rows:
return []
header = rows[0]
records = []
for number, row in enumerate(rows[1:], start=2):
if len(row) != len(header):
raise ValueError(
f"row {number} has {len(row)} fields, "
f"but the header has {len(header)}"
)
records.append(dict(zip(header, row)))
return records
Line 1 is the header, so the first data row is line 2 — and that is the number somebody will type into their editor to go and look. enumerate takes a start argument precisely for this. An error saying 'row 0' sends the reader to the wrong place, which is worse than no number at all.
Everything else is one well-known idiom. The check exists because zip’s silence is a good default for pairing things you control and a terrible one for parsing a file you do not. Knowing which of your tools fail quietly, and putting a guard in front of those, is most of what defensive parsing is.
'row 4 has 3 fields, but the header has 4' tells you what happened; 'malformed row' tells you only that something did. The extra few characters are written once and read every time the export breaks.
Do not parse real CSV text with split(',') and this function — that is what the csv module is for, and csv.DictReader does exactly this job including quoted fields containing commas, embedded newlines, escaped quotes and the several dialects spreadsheets emit. This exercise starts from rows that are already split precisely so the quoting problem is out of scope. For anything beyond a few thousand rows of numeric data, pandas.read_csv or polars will be faster and give you types as well. Write this when you have rows in hand and want them keyed.
- Add a strict=False option that pads short rows with empty strings instead of raising, and decide which behaviour a nightly import job should default to.
- Detect duplicate column names in the header — dict() silently keeps only the last — and decide whether that deserves an error or a rename.
- Make it lazy with yield so a million-row export never has to be in memory as a list, and notice what that costs a caller who wanted len().