Billing Replay

PYTHON / SEPTEMBER 7, 2026

DictReader is not a schema: validate CSV headers before building dictionaries

A CSV file can parse successfully and still lose information when you turn its rows into dictionaries. This happens before you check whether an email address is valid or a required name is present. If two columns have the same header, their values compete for one dictionary key. A useful first boundary is therefore structural: make sure that every column has a distinct name and every record has the expected number of cells.

Start with a tiny example you can run without installing anything:

import csv
import io

text = "Name,Email,Email\nExample,first@example.invalid,last@example.invalid\n"
print(next(csv.DictReader(io.StringIO(text, newline=""))))

On the Python 3.12.14 runtime used for this sample, the output is:

{'Name': 'Example', 'Email': 'last@example.invalid'}

The input contains three cells, but the resulting dictionary has two keys. The second email occupies the repeated key. Checking the dictionary afterward cannot recover the first email or tell you that the original record had two email columns. Validate the header list while it is still a list.

Uneven rows need a separate check. DictReader accommodates extra cells by placing them in a list under its restkey, which defaults to None. It fills missing cells with restval, also None by default. These behaviors are useful for flexible input, but they do not enforce a fixed number of columns. Python's `DictReader` documentation describes both cases.

The following generator makes a small, explicit contract. It accepts comma-separated UTF-8 files, including a UTF-8 byte order mark. Headers must be nonempty and unique; each later record must have exactly as many cells as the header. Save it as strict_csv.py:

import csv


class CsvShapeError(ValueError):
    pass


def checked_rows(path):
    with open(path, encoding="utf-8-sig", newline="") as source:
        reader = csv.reader(source, strict=True)
        try:
            headers = next(reader, None)
            if headers is None:
                raise CsvShapeError("Empty file: expected a header")
            if not headers or any(not name.strip() for name in headers):
                raise CsvShapeError("Header names must be nonempty")
            if len(headers) != len(set(headers)):
                raise CsvShapeError("Duplicate header names")

            for record, cells in enumerate(reader, 1):
                line_end = reader.line_num
                if len(cells) != len(headers):
                    raise CsvShapeError(
                        f"Record {record}, ending at physical line {line_end}: "
                        f"expected {len(headers)} cells, got {len(cells)}"
                    )
                yield record, line_end, dict(zip(headers, cells))
        except csv.Error as error:
            raise CsvShapeError(
                f"CSV syntax near physical line {reader.line_num}: {error}"
            ) from error

The order matters. A set removes repeated elements, so a shorter set of headers reveals a duplicate. The width check then runs before zip() combines headers and cells. Without that check, zip() would stop at its shorter input and could conceal a mismatch. Only after both checks does the function construct a dictionary.

There are two different meanings of “strict” here. The parser's strict=True enables errors for malformed input it detects, such as an unfinished quoted field. Our additional code enforces the header and width rules. Parser strictness alone does not make column names unique or declare which columns an application requires. Opening with newline="" lets the CSV parser handle newlines, including those inside quoted cells. Python's CSV documentation explains these parser options.

To try the function, place this code beside the module. It writes only a synthetic file in a temporary directory:

from pathlib import Path
from tempfile import TemporaryDirectory
from strict_csv import CsvShapeError, checked_rows

with TemporaryDirectory() as directory:
    path = Path(directory) / "example.csv"
    path.write_bytes(b"Name,Email\nExample,one@example.invalid,extra\n")
    try:
        print(list(checked_rows(path)))
    except CsvShapeError as error:
        print(error)

The accompanying demo produced these diagnostics for duplicate headers, an extra cell, and a missing cell, respectively:

Duplicate header names
Record 1, ending at physical line 2: expected 2 cells, got 3
Record 1, ending at physical line 2: expected 2 cells, got 1

An empty value is different from a missing cell. With two headers, Example, has two cells and passes this structural check. Example has one cell and fails. Whether an empty email is acceptable belongs in a later value check. Keeping that distinction makes error messages more useful: a missing delimiter and a missing business value need different fixes.

Location reporting also needs care. Consider a quoted note containing a newline:

Name,Note
Example,"first line
second line"
Other,short

The first data record ends on physical line three; the second ends on line four. The demo returned:

[(1, 3, {'Name': 'Example', 'Note': 'first line\nsecond line'}), (2, 4, {'Name': 'Other', 'Note': 'short'})]

Our enumerate() counter labels data records, excluding the header. reader.line_num supplies the physical line where the parser finished reading that record. Those numbers describe different things, so the diagnostic names both. Python documents this distinction; treating each physical line as a complete CSV record would break this multiline example.

The remaining choices are deliberately visible. Header spelling is exact and case-sensitive: "Email" and " Email" are different names; the second has a leading space. The emptiness check uses strip(), but the returned names remain unchanged. Blank data records fail the width check. A header-only file is valid and yields nothing; a completely empty file fails because it provides no header.

This generator validates records as you request them. Creating the generator does not read the file, and receiving one valid record does not validate later records. One test consumes a valid row and then encounters a short row. For a small file, collecting the iterator into a list completes structural validation before you process its rows. That approach holds the rows in memory. Larger workflows need their own staging or transaction strategy; this reader provides no rollback for actions already performed by its caller.

Ten automated tests passed on Python 3.12.14. They cover duplicate and empty headers, uneven widths, empty values, quoted commas and doubled quotes, multiline CRLF input, malformed quotations, a byte order mark, blank records, empty and header-only files, and delayed failure during iteration. The multiline test also checks that the input bytes remain unchanged. Run them from the accompanying source directory with python3 -m unittest -v, using a supported interpreter; Python 3.12 remains in security support. Python version status

These checks establish a predictable row structure. They do not verify expected column names, email syntax, allowed values, or whether a later import should update an existing record. Add those rules against the application's actual contract after preserving the input faithfully.

Disclosure: Codex authored this original draft, implementation, and synthetic tests, and executed the demo and test suite locally. This is a writing sample for review, using invented records and reserved .invalid email addresses.

Reproduce the examples.

The bundle includes both Python articles, source code, synthetic fixtures, and tests. Keep its folders together and follow the root README. Verified on Python 3.12.14; no runtime packages or account connection required.

Download both samples and tests →