A CSV import can lose information before business validation starts. If two columns have the same header, converting the row into a dictionary cannot preserve both values under that key. A row with too few or too many fields also needs an explicit policy rather than accidental truncation.
This example validates the table shape before building dictionaries. It trims header whitespace, rejects blank or duplicate normalized names, and requires every data row to have exactly the header width. It leaves cell values unchanged.
Prerequisites
Use Python 3.12 or another supported Python 3 release. Save the following program as import_csv.py. It uses only csv and io from the standard library.
import csv
import io
def load(text):
reader = csv.reader(io.StringIO(text, newline=''), strict=True)
header = next(reader, None)
if not header or any(not name.strip() for name in header):
raise ValueError('missing header')
names = [name.strip() for name in header]
if len(set(names)) != len(names):
raise ValueError('duplicate header')
rows = []
for row in reader:
if len(row) != len(names):
raise ValueError(f'wrong width near line {reader.line_num}')
rows.append(dict(zip(names, row)))
return rows
rows = load('name,note\nAda,"one,two"\nBob,"line1\nline2"\n')
assert rows[0]['note'] == 'one,two'
assert rows[1]['note'] == 'line1\nline2'
assert load('name,note\n') == []
for text in ['', 'x, x\n1,2\n', 'x,\n1,2\n',
'x,y\n1\n', 'x,y\n1,2,3\n', 'x\n"unfinished']:
try:
load(text)
except (ValueError, csv.Error):
pass
else:
raise AssertionError('invalid CSV accepted')
print(f"rows={len(rows)} quoted_comma={rows[0]['note']}")
print('6 malformed fixtures rejected')
Run and expected output
python3 import_csv.py
rows=2 quoted_comma=one,two
6 malformed fixtures rejected
Why parsing comes first
The csv reader understands quoted delimiters and embedded newlines. Splitting the input on commas or newline characters would corrupt both valid fixtures. Shape checks happen on parsed records, so a comma inside a quoted field does not count as another column.
Only after confirming the row width does the loader call zip. That ordering prevents zip's normal shortest-input behavior from silently dropping extra cells. Header normalization happens before duplicate detection, so x and a space-padded x collide intentionally. Header-only input is accepted as an empty table.
Edge cases and troubleshooting
The header comparison is case-sensitive: Name and name remain distinct. If the destination treats them as equivalent, add that normalization before the duplicate check and test collisions. A blank data record is rejected by the width rule rather than silently ignored. Decide whether that matches the producer's contract.
The reader's line_num identifies physical source lines, not necessarily record numbers, because quoted fields can span lines. Keep that distinction in user-facing diagnostics. strict=True catches malformed CSV syntax, while the explicit checks enforce this application's table contract; it does not establish that an age or monetary cell has the correct type.
For disk files, open with an explicit encoding and newline='', as recommended by the CSV documentation. If the source includes a UTF-8 byte-order mark, choose an encoding policy deliberately. This in-memory demonstration collects all records; a large import should validate and stream into a staging area, then commit only after the whole import satisfies its transaction policy.
Primary references
The official Python CSV reference documents quoting, strict parsing and newline handling. Find related examples in Python tutorials.