Python CSV Contracts: Parse Records Before You Validate Values
Handle quoted commas and newlines, reject malformed row shapes, preserve identifiers, and define a CSV contract that survives round trips.
Reviewed October 8, 2026. Examples target Python 3.12 and use synthetic in-memory CSV data.
A CSV file can look simple while containing more structure than a line split reveals. A comma may belong inside a quoted field. A newline may belong inside a quoted note. An identifier such as 003 may be text even though every character is a digit. Correct ingestion requires two different steps: parse the file's record structure, then validate each field according to its meaning.
Python's standard csv module provides the structural parser. It does not decide which headers are required, whether a blank score is acceptable, or whether an identifier may lose leading zeros. This article builds an explicit contract around that parser and keeps malformed records visible instead of silently forcing them into a convenient table.
1. Declare the format separately from the data schema
A dialect describes delimiter, quoting, escaping, and related parsing rules. A schema describes fields, allowed values, missing-value policy, and types. Two files with the same columns may use different delimiters; two files with identical dialects may describe entirely different data. Avoid treating those concepts as interchangeable.
When reading an actual file, use an explicit encoding and newline="" with the CSV module so it can handle newlines correctly. A byte-order mark may require a documented encoding choice such as utf-8-sig; that decision should come from the input contract, not an unexplained repair after headers fail to match.
Dialect detection can help investigate an unfamiliar file, but detection is not verification. Sniffer examines a sample, and its header-detection method is explicitly heuristic. A small sample can omit an important quoted record or unusual value. Record the inferred dialect, inspect representative data, and settle on a repeatable parsing contract before analysis.
2. Prove that quotes and newlines survive a round trip
import csv
from io import StringIO
records = [
{"id": "003", "note": "alpha, beta"},
{"id": "004", "note": "first line\nsecond line"},
]
buffer = StringIO(newline="")
writer = csv.DictWriter(buffer, fieldnames=["id", "note"],
lineterminator="\n")
writer.writeheader()
writer.writerows(records)
serialized = buffer.getvalue()
parsed = list(csv.DictReader(StringIO(serialized, newline="")))
assert parsed == records
assert parsed[0]["id"] == "003"
assert len(serialized.splitlines()) > len(records) + 1
print(repr(serialized))
The first note requires quoting because it contains a comma. The second note spans physical lines but remains one parsed field in one record. The writer and reader preserve these values under the chosen dialect. A manual comma split would break the first note; treating each physical line as a record would break the second.
The identifier stays a string. Converting it to an integer and later converting back would produce 3, losing the original representation. Numeric-looking text should be converted only when arithmetic meaning is part of the schema. Account codes, postal codes, and identifiers often require textual preservation, though each actual dataset needs its own rule.
3. Check row shape before using DictReader
DictReader can represent extra fields under a special key and fill absent fields with a default value. That behavior can be useful, but it also means successful iteration does not prove that every row matched the header. Duplicate header names create another problem: mapping to a dictionary can obscure a repeated field.
For a strict small contract, inspect the parsed header and row widths before constructing records. The following example accepts one valid row and retains a reason for each rejection. It deliberately separates shape errors from value errors.
import csv
from io import StringIO
text = "id,minutes\n003,30\n004\n005,20,extra\n006,-1\n"
reader = csv.reader(StringIO(text, newline=""), strict=True)
header = next(reader)
assert header == ["id", "minutes"]
assert len(set(header)) == len(header)
accepted, rejected = [], []
for record_number, fields in enumerate(reader, start=2):
if len(fields) != len(header):
rejected.append((record_number, "field_count"))
continue
identifier, raw_minutes = fields
if not (identifier.isascii() and identifier.isdecimal()
and len(identifier) == 3):
rejected.append((record_number, "id_format"))
continue
try:
minutes = int(raw_minutes)
except ValueError:
rejected.append((record_number, "minutes_format"))
continue
if not 0 <= minutes <= 240:
rejected.append((record_number, "minutes_range"))
continue
accepted.append({"id": identifier, "minutes": minutes})
assert accepted == [{"id": "003", "minutes": 30}]
assert rejected == [(3, "field_count"), (4, "field_count"),
(5, "minutes_range")]
assert len(accepted) + len(rejected) == 4
print(accepted, rejected)
These record numbers count parsed records, including the header. They are not physical line numbers. For inputs with embedded newlines, keep both concepts distinct; the reader's line tracking can support diagnostics, but it refers to physical input consumption. A parse error also needs a file-level handling policy because structural recovery may not be reliable.
| Input condition | Parser result | Additional contract |
|---|---|---|
| Quoted comma | One field containing a comma | Validate the field's semantic value |
| Quoted newline | One record spanning lines | Report record and line positions carefully |
| Extra or absent field | A row with unexpected width | Reject, quarantine, or explicitly map |
| Numeric-looking ID | Usually a string | Preserve representation unless conversion is intended |
| Empty field | Empty text under ordinary quoting | Distinguish blank, missing, and invalid values |
4. Missing values require a reversible policy
With ordinary writer settings, None is written as an empty field, which can lose the distinction between an absent value and an actual empty string. Define that distinction before claiming a round trip is lossless. A reserved sentinel can work only if its literal occurrence in real data has an escaping or rejection rule.
Python 3.12 introduced additional quoting constants related to null and string handling, but its documentation notes a reader bug for QUOTE_NOTNULL and QUOTE_STRINGS that was fixed in Python 3.13. This article does not depend on those modes. Testing the exact installed version and the exact reader/writer pair is more reliable than assuming that a feature name guarantees identical behavior everywhere.
Also distinguish malformed structure from permitted absence. A row missing its last field may violate the record shape even when an explicitly empty last field is permitted. Once both become a single missing marker in a table, that distinction can be hard to recover. Preserve raw input or a sufficient rejection audit before normalizing.
5. CSV quoting is not a type system
Quoting controls how field boundaries are serialized. It does not establish that a value is safe for every application that opens the file, nor that it has the intended type. A spreadsheet application can interpret text using its own import rules. For exports intended for spreadsheets, define identifier and formula-handling behavior with that consumer rather than assuming ordinary CSV quotation overrides it.
Limit accepted numeric syntax deliberately. The sample uses int, which accepts more representations than a digits-only contract. If whitespace, signs, or other accepted forms are prohibited, validate those rules separately. Reject values outside their domain after parsing; a syntactically valid negative duration remains invalid for this example.
6. Verify the complete ingestion contract
Test the parser with a quoted delimiter, embedded newline, repeated header, empty field, short row, and long row. Test semantic validation with valid boundaries and invalid values. Reconcile accepted plus rejected records with the number of parsed data records, while treating an unrecoverable structural parse failure separately.
For a large input, apply the same rules incrementally rather than building an unbounded list. Streaming changes resource and memory behavior; it should not silently weaken schema checks or discard rejection reasons. As an exercise, add a blank minutes value to the fixture and choose whether it is a rejection or a supported missing value. State how that choice changes downstream totals and denominators.
Sources and scope
- Python 3.12: CSV module, dialects, and reader limitations.
- RFC 4180: common CSV structure.
- Python 3.12: text I/O and newline handling.
The schema and rejection codes are original synthetic examples. RFC 4180 describes a common format; it does not establish that every producer or consumer uses that dialect.