strict=True sounds like an application guarantee. It is not. Python can strictly parse a CSV record and still return a perfectly usable object for a row that has only two fields where the consumer requires three, or four fields where the consumer requires exactly three. DictReader can make the result look especially convenient: it fills an absent value with None, or stores surplus values under a special key. The mapping exists, but the source record still violates the declared file contract.
That distinction matters before transformation begins. A missing third field is not the same source state as an explicitly empty third field. An extra fourth field is not harmless merely because a projection can ignore it. A quoted comma is not a second delimiter, and a quoted CRLF is not a second logical record. Any validator that flattens those differences before preserving evidence has made repair less accountable.
This article builds a bounded twelve-case laboratory around CPython 3.13.5, the standard-library csv module, the excel dialect, strict=True, and the exact ordered header id,label,value. Every admitted field remains a string. The gate parses with the selected CSV dialect, checks width before dictionary construction, stages the whole file, and publishes only after complete success. The resulting decisions are ACCEPT_FILE, REJECT_WIDTH, REJECT_SYNTAX, REJECT_HEADER, or HOLD. The gate never silently truncates, pads, skips, or renames fields.
Define the three-column file contract before parsing
The application contract is intentionally small and explicit:
The first logical record must be exactly id,label,value, in that order.
Every subsequent logical record must contain exactly three parsed fields.
All admitted values remain Python strings.
An explicitly empty third field is allowed, so r4,empty, is valid and maps value to "".
An absent third field is invalid, so r2,short is rejected rather than repaired.
No accepted data becomes visible until the entire source string has passed header, syntax, and row-width checks.
These rules are an application decision, not a universal CSV law. Another system may permit variable-width records, prohibit empty values, use a different header, or define no header at all. The important engineering step is to declare the policy before observing parser output, rather than deriving a supposed contract from whatever the parser happens to return.
Write the contract so that a reviewer can decide each boundary case without reading the implementation. For example: a file containing only the exact header is structurally acceptable if zero data records are permitted; a blank record after that header is not; an empty third string is allowed; a missing third field is not. If the team has not decided one of those points, the correct workflow state is HOLD, not an improvised parser default. This separation also makes change review precise: changing the dialect, changing the header, and changing the empty-field policy are three different contract revisions.
The file identity belongs in the same contract. Retain the original source bytes or an immutable object reference, a digest, the selected dialect, the decoding decision, and the admission-policy version. That boundary can sit inside much larger cloud-native pipeline boundaries, but a managed service, container, or orchestration layer does not decide whether a two-field row satisfies a three-field business requirement.
This laboratory uses complete in-memory strings to keep the proof focused. In a production pipeline, ACCEPT_FILE should identify a specific immutable source, not merely a filename that could later point to different bytes.
Separate strict grammar from application schema
The Python 3.13 csv documentation defines Dialect.strict narrowly: when true, the parser raises csv.Error on bad CSV input. The same documentation says csv.reader returns each row as a list of strings and describes dialect settings such as delimiters, quoting, escaping, and newline behavior. None of that states that strict=True enforces an application-required number of fields.
The historical boundary is even more direct in PEP 305. Its scope is parsing tabular data across varying separators, quote rules, escape mechanisms, and line endings. When the PEP asks how different-length rows should be handled, it assigns interpretation to the application and says that “short” and “long” are not parser-level concepts. That design is exactly why the width predicate belongs after grammatical parsing and before business mapping.
RFC 4180 documents a common convention that records should have the same field count and that headers should correspond to those fields. It also permits a final record without a terminating line break and describes quoted commas and quoted CRLF sequences. But RFC 4180 was published in October 2005 as an Informational RFC, not an Internet Standards Track specification. It is useful context, not proof that every producer follows one universal CSV grammar or that Python automatically applies this article’s three-column policy.
A related DuckDB import and reject reconciliation workflow follows records into typed database materialization. This article stops earlier: exact strings must first pass grammar, header, and cardinality checks before any typed import is considered.
Name the extra and missing-field behaviours
Python's DictReader documentation explains how rows are mapped; it does not define an exact-width validator. Its restval fills missing values on a non-blank short row, and its restkey receives a list of surplus values on a long row. Both default to None. Thus a two-field source row can become {'id': 'r2', 'label': 'short', 'value': None}, while a four-field row can gain an additional None: ['tail'] entry.
Those outcomes are useful evidence, but they are not admissions. Converting the missing None to "", deleting the special key, or stringifying every key and value would collapse distinct source states. The gate therefore uses csv.reader first, checks len(values), and constructs a business dictionary only after width equals three.
Preserve the input and newline environment
A reproducible admission result needs more than the visible CSV text. Record the interpreter, operating system boundary, source digest, per-input digest, parser configuration, and newline handling. The supplied commissioning record identifies fixture csv-width-v1, CPython 3.13.5 on Linux-6.18.44-x86_64-with-glibc2.41, and source SHA-256 d5ea9f82164e26afd19688730edb8b1524bef6f01129d28ff7f0ce9c9e2c74df. Its twelve cases are synthetic UTF-8-compatible strings, so no character-encoding detector or external file is part of the evidence.
The stream is created as io.StringIO(text, newline=''). Python's io documentation explains that newline='' enables universal newline recognition while returning line endings untranslated; StringIO uses the corresponding text-stream newline rules. The csv documentation recommends newline='' for CSV file objects because the module performs its own newline handling; without it, embedded newlines in quoted fields may not be interpreted correctly.
In this fixture, the authoritative inputs are already Python strings, and each input digest is calculated from text.encode('utf-8'). That is a controlled laboratory choice, not an encoding-detection result. A production receiver should normally hash and retain the original byte object before decoding, then record the approved encoding and decoding error policy. If the original bytes are missing, a hash was calculated only after an undocumented rewrite, or provenance cannot connect the text to a specific source object, structural parsing may still be repeatable while file admission remains HOLD.
That choice matters in C6. Its label contains the exact characters two\r\nlines inside quotes. Preprocessing with splitlines() would turn a valid logical record into fragments and destroy the original CRLF evidence. Splitting each physical line on commas would also misclassify C5, whose quoted label is "a,b".
Keep these identities together:
Evidence item | Recorded value or rule |
Parser runtime | CPython 3.13.5 |
Dialect | excel |
Syntax mode | strict=True |
Text stream | StringIO(..., newline='') |
Header | ['id', 'label', 'value'] |
Value domain | Strings only |
Source identity | Fixture source digest plus each input digest |
Publication rule | Return staged records only after complete success |
A later rerun is a new observation. It should record its own environment and timestamps rather than overwriting the supplied commissioning evidence.
Run the complete twelve-case admission laboratory
Save the following file as csv_fixture.py in a writable disposable directory, then run it with the intended interpreter:
python csv_fixture.pyThe script creates a new temporary results directory beside itself and prints the path to results.json. The script treats DATA as the fixed test inventory: each case binds one exact source string to one expected classification. Rejected carries a reason, logical data-record ordinal, physical end line, and count of already staged rows. admit() parses the header and records, checks width before dict(zip(...)), and returns the staged list only after reaching end of input without rejection.
The temporary directory prevents a new run from silently replacing an earlier report. The report records start and finish timestamps, interpreter and platform strings, the fixture-source digest, every input digest, expected and observed reasons, publication state, and targeted comparison evidence. On an exception in the driver itself, the finally block still writes the report before the exception is re-raised, so an incomplete run is not mistaken for an absent run. Review status first: only PASS_12_CASES means the complete inventory reached all assertions.
"""Fixed CSV dialect and three-column application contract; synthetic strings."""
import csv
import hashlib
import io
import json
import platform
import tempfile
from datetime import datetime, timezone
from pathlib import Path
HEADER = ['id', 'label', 'value']
PREFIX = 'id,label,value\r\n'
DATA = {
'C1': (PREFIX + 'r1,ok,10\r\n', 'ACCEPT'),
'C2': (PREFIX + 'r2,short\r\n', 'WIDTH'),
'C3': (PREFIX + 'r3,extra,30,tail\r\n', 'WIDTH'),
'C4': (PREFIX + 'r4,empty,\r\n', 'ACCEPT'),
'C5': (PREFIX + 'r5,"a,b",50\r\n', 'ACCEPT'),
'C6': (PREFIX + 'r6,"two\r\nlines",60\r\n', 'ACCEPT'),
'C7': (PREFIX + 'r7,"unterminated,70\r\n', 'SYNTAX'),
'C8': (PREFIX + 'r1,ok,10\r\nr2,ok,20\r\nr3,short\r\n', 'WIDTH'),
'C9': (PREFIX + 'r1,ok,10\r\nr4,empty,\r\n', 'ACCEPT'),
'C10': ('label,id,value\r\nok,r1,10\r\n', 'HEADER'),
'C11': (PREFIX + '\r\n', 'WIDTH'),
'C12': (PREFIX + 'r12,ok,120', 'ACCEPT'),
}
class Rejected(ValueError):
def **init**(self, reason, record, physical_end_line, staged_count):
super().\_\_init\_\_(reason)
self.details = dict(reason=reason, record=record,
physical_end_line=physical_end_line,
staged_count=staged_count)
def admit(text):
reader = csv.reader(io.StringIO(text, newline=''), dialect='excel', strict=True)
staged = []
record = 0
try:
if next(reader, None) != HEADER:
raise Rejected('HEADER', 0, reader.line_num, 0)
for record, values in enumerate(reader, 1):
if len(values) != len(HEADER):
raise Rejected('WIDTH', record, reader.line_num, len(staged))
staged.append({'record': record, 'physical_end_line': reader.line_num,
'values': dict(zip(HEADER, values))})
except csv.Error as error:
raise Rejected('SYNTAX', record + 1, reader.line_num, len(staged)) from error
return staged
if **name** == '\_\_main\_\_':
dest = Path(tempfile.mkdtemp(prefix='csv-run-', dir=Path(\_\_file\_\_).parent))
report = {'fixture': 'csv-width-v1', 'status': 'FAIL', 'cases': [],
'python': platform.python_version(), 'platform': platform.platform(),
'started_utc': [datetime.now](http://datetime.now)(timezone.utc).isoformat(),
'source_sha256': hashlib.sha256(Path(\_\_file\_\_).read_bytes()).hexdigest()}
try:
for identity, (text, expected) in DATA.items():
row = {'id': identity, 'status': 'FAIL', 'expected': expected,
'input_sha256': hashlib.sha256(text.encode('utf-8')).hexdigest()}
report['cases'].append(row)
published = None
try:
published = admit(text)
reason = 'ACCEPT'
except Rejected as error:
reason = error.details['reason']
row['rejection'] = error.details
row\.update(observed=reason, published=published)
if reason != expected or (expected != 'ACCEPT' and published is not None):
raise AssertionError(identity)
if identity in {'C2', 'C3', 'C4', 'C11'}:
# Field-presence pairs preserve the special None key in JSON evidence.
raw = list(csv.DictReader(io.StringIO(text, newline=''), strict=True))
row['dictreader_pairs'] = [list(r.items()) for r in raw]
if identity == 'C2' and raw[0]['value'] is not None:
raise AssertionError('Short-row default changed')
if identity == 'C3' and raw[0][None] != ['tail']:
raise AssertionError('Extra-field capture changed')
if identity == 'C4' and raw[0]['value'] != '':
raise AssertionError('Empty string changed')
if identity == 'C11' and raw != []:
raise AssertionError('Blank-row comparison changed')
if identity == 'C6':
if published[0]['values']['label'] != 'two\r\nlines':
raise AssertionError('Quoted newline changed')
if published[0]['physical_end_line'] != 3:
raise AssertionError('Physical-line count changed')
if identity == 'C8' and row['rejection']['staged_count'] != 2:
raise AssertionError('Late-rejection control missing')
row['status'] = 'PASS'
if len(report['cases']) != 12:
raise AssertionError('Incomplete inventory')
report['status'] = 'PASS_12_CASES'
except BaseException as error:
report['error'] = {'type': type(error).__name__, 'message': str(error)}
raise
finally:
report['finished_utc'] = [datetime.now](http://datetime.now)(timezone.utc).isoformat()
path = dest / 'results.json'
path.write_text(json.dumps(report, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
print(path)The supplied commissioning execution reports PASS_12_CASES, started at 2026-10-13T07:37:38.744859+00:00 and finished at 2026-10-13T07:37:38.745702+00:00. Those timestamps identify one very small run; they are not a performance benchmark. More importantly, the source digest in that report matches the complete driver above, so the observation does not depend on an unpublished helper.
The assertion structure distinguishes test success from file acceptance. C2 passes its diagnostic test because the gate rejects it as WIDTH; C7 passes because it is classified SYNTAX; C10 passes because the wrong header is rejected. Only cases whose observed reason is ACCEPT receive a staged list in published. For every expected rejection, the driver asserts that published is None.
The laboratory also includes targeted controls rather than relying on twelve labels alone:
C2, C3, C4, and C11 record DictReader key/value pairs to preserve its missing, extra, empty, and blank-row behavior.
C6 asserts that the embedded value remains exactly two\r\nlines and that the parser ends the record on physical line 3.
C8 asserts that two rows were staged before the late short row was found.
Every input receives its own SHA-256 digest.
The inventory must contain exactly twelve cases.
This is an executable small-file demonstration. It does not claim to be a streaming parser, a memory-bounded bulk loader, a database transaction manager, or an exactly-once delivery system. The whole-file staging design is deliberately visible so reviewers can decide whether its memory and side-effect boundaries fit their own sink.
The record variable also deserves attention. It starts at zero and is updated only after the reader successfully returns a logical row through enumerate. If parsing C7 raises before a row is returned, the syntax handler reports record + 1, identifying the next data-record ordinal under examination. That context is useful, but the retained exception chain and source remain authoritative; the ordinal does not prove where an unmatched quote should have closed.
Keep parser evidence separate from publication state
Inside admit(), staged is private work-in-progress. Parsing a valid prefix is evidence that the parser reached those rows; it is not authorization to publish them. On C8, two dictionaries exist temporarily, but the third record fails width validation, an exception escapes, and the caller’s published variable remains None.
That is a Python return contract, not automatic rollback. If a real implementation sends each staged row to a queue, writes it to a table, updates a file, or invokes an API before end-of-file acceptance, those side effects require their own staging or compensation design. The helper proves only that this function does not return a partial list as an accepted file.
Compare a missing field with a genuinely empty field
C1 establishes the ordinary baseline. Its source record is r1,ok,10\r\n, the parser returns ['r1', 'ok', '10'], width is three, and the admitted mapping is {'id': 'r1', 'label': 'ok', 'value': '10'}. No special handling is needed.
C2 is structurally different: r2,short\r\n contains only two fields. The CSV grammar itself is usable, so strict=True has no syntax reason to raise. csv.reader returns a two-element list, and the application width gate raises Rejected('WIDTH', ...). The recorded rejection is data record 1, physical end line 2, staged count 0, with no publication.
For comparison, DictReader represents C2 as these ordered pairs:
('id', 'r2')
('label', 'short')
('value', None)That None is supplied by the reader’s default restval; it was not present in the source. Treating it as an empty string would erase the distinction between absence and explicit emptiness.
C4 supplies that positive contrast. Its record is r4,empty,\r\n. The trailing delimiter creates a third parsed field whose value is exactly "". Both csv.reader and DictReader retain that empty string, the width is three, and the file is accepted under this laboratory’s empty-field policy.
The three states are therefore:
Case | Source ending | Parsed third-field state | Decision |
C1 | ,10 | Present, nonempty string | ACCEPT |
C2 | no third delimiter or field | Absent; DictReader supplies None | WIDTH |
C4 | trailing , | Present, empty string | ACCEPT |
This is why a cleanup step must not run before structural admission. Replacing None with "" may be a valid downstream business rule in some system, but here it would fabricate source presence and turn a rejected record into an apparently conforming one.
Detect extra fields before constructing business dictionaries
C3 contains r3,extra,30,tail\r\n. The parser can identify four legitimate CSV fields, so strict grammar parsing succeeds. The application, however, requires exactly three. len(values) is four, and the gate records WIDTH at data record 1, physical line 2, with no staged or published rows.
DictReader preserves the mismatch differently. With the header id,label,value and default restkey=None, its ordered pairs are:
('id', 'r3')
('label', 'extra')
('value', '30')
(None, ['tail'])That special entry is evidence that the source row exceeded the named fields. It is not a normal business column, and it should not be dropped merely to make the dictionary fit a downstream interface. The Python DictReader documentation states that remaining values are collected in a list under restkey, whose default is None.
The dangerous ordering is:
mapped = dict(zip(HEADER, values))
if len(mapped) == len(HEADER):
accept(mapped)For C3, zip() stops after the three header names. tail disappears, mapped has three keys, and the later length test can no longer reveal the original four-field row. The safe ordering is the fixture’s ordering: first compare len(values) with len(HEADER); only then build the dictionary.
A convenient mapping is not the original row shape
A Python mapping answers “which value is associated with this key?” It does not necessarily answer “how many fields were present in the source record?” DictReader retains useful clues through restkey and restval, but subsequent normalization can destroy them.
The fixture serializes list(r.items()) instead of serializing each dictionary as a JSON object. That keeps the DictReader special key in the first position of a key/value pair, where Python None appears as JSON null; an ordinary field name remains a JSON string. The captured surplus value remains ['tail']. This representation preserves the distinction between the special key and a literal header such as "null".
Do not “repair” C3 by dropping the special pair, renaming it, or truncating values. Any approved extra-column policy belongs in a new contract with evidence showing why that fourth value is safe to discard, route, or adopt.
Retain quoted commas and quoted physical newlines
C5 and C6 are positive controls against naive delimiter and line counting. They must succeed, because their apparent complexity is valid under the selected dialect.
C5 is:
id,label,value\r\n
r5,"a,b",50\r\nThe comma inside quotes belongs to the label. csv.reader returns ['r5', 'a,b', '50'], so the width is exactly three. A raw line.split(',') would produce four fragments and falsely reject the row. PEP 305 motivates a purpose-built parser because comma splitting eventually fails when delimiters appear inside data governed by quoting rules.
C6 is:
id,label,value\r\n
r6,"two\r\n
lines",60\r\nIt contains one logical data record spanning two physical data lines. The admitted label must remain exactly two\r\nlines, including both carriage return and line feed. The parser's line_num becomes 3 when that record is returned: physical line 1 is the header, and physical lines 2 and 3 form the quoted record. The Python csv documentation explains that line_num counts source lines read, not records returned, because records can span multiple lines.
RFC 4180 likewise describes fields containing commas or CRLF sequences as quoted fields. Its final-record rule also supports C12's separate positive control: a complete last record need not have a terminating line break.
These controls show why delimiter counts and physical-line counts are not independent validation parsers. They can support byte-level diagnostics after parsing, but they cannot replace the dialect-aware grammar. Preserve the original string, the logical record ordinal, physical_end_line, and the exact parsed value so a reviewer can relate the accepted mapping back to its source representation.
A useful review question is whether a proposed “independent check” actually uses an independent model. Counting commas repeats an assumption that quoted commas disprove. Counting physical lines repeats an assumption that quoted CRLF disproves. A better secondary check can verify source digest, expected case inventory, or reconciliation between accepted logical-record identifiers and an independently authored manifest. It should not apply a weaker imitation of CSV parsing and then overrule the real parser.
Reject bad syntax without relabelling it as a width defect
C7 is r7,"unterminated,70\r\n after the valid header. The opening quote is never closed. With the excel dialect and strict=True, the underlying reader raises csv.Error; the fixture catches that exception and translates it to Rejected('SYNTAX', ...). The supplied observation records data record 1, physical end line 2, staged count 0, and published=None.
The classification order matters. No trustworthy field list exists for that record, so a width check cannot determine whether the application received two, three, or four fields. Calling the failure WIDTH would imply the grammar had already established field boundaries. It had not.
The Python csv documentation states that strict=True raises csv.Error on bad CSV input, and it defines csv.Error as the module's parser error class. That gives the application a documented syntax-failure channel without turning every schema mismatch into a grammar exception.
A syntax error can also prevent complete tail discovery. Once quoting makes record boundaries unresolved, the parser may consume physical lines while looking for a closing quote and then stop. The absence of later reported failures is not evidence that later bytes contain no defects. This laboratory has no rows after C7’s malformed record, but the operational rule remains: classify what the parser reached and retain the complete source for producer repair or controlled reprocessing.
Record what the parser actually reached
Use separate coordinates for separate concepts:
record is the application’s one-based logical data-record ordinal after the header.
physical_end_line is reader.line_num, the count of physical source lines consumed when the record or error was reached.
staged_count is the number of exact-width data records held before rejection.
The source digest identifies the bytes or, in this fixture, the encoded source string.
line_num is not a byte offset, character offset, or logical row ID. Do not report “error at character 17” unless another evidence mechanism actually measured that position. For multiline records, even “row 3” is ambiguous unless the report says whether it means logical record ordinal or physical source line.
Make blank-record and header-order policy visible
C10 uses the header label,id,value. It contains familiar names, but their order violates the declared precondition. next(reader, None) != HEADER therefore produces HEADER before any data record is staged. The gate does not silently reorder columns, because doing so would change the contract from positional exactness to name-based alignment.
C11 is different. Its header is correct, followed by a blank physical record. csv.reader returns an empty list for that record, so its width is zero and the gate classifies WIDTH. The DictReader comparison returns no dictionaries because blank rows are skipped in that interface. These results are not contradictory: they expose different reader-level representations, while the application has deliberately chosen to reject a blank record that appears after the header.
This contrast is operationally important. If an ingestion owner switches from reader to DictReader without a regression case for blank records, the apparent data population can change even though the source bytes do not. The policy should say whether blank physical records are prohibited, ignored, or meaningful, and the evidence should show which API behavior implemented that policy. Here the answer is explicit rejection.
C12 confirms another visible policy. Its final record is r12,ok,120 with no trailing newline. It parses to three fields and is accepted. The application does not invent a requirement for a terminal CRLF when the selected grammar can return a complete final record.
Header order, blank-record treatment, and empty-field treatment must be reviewable decisions. They should not emerge accidentally from whichever API is shortest to call. Downstream value work such as preserving meaning during Python data cleaning begins only after this structural boundary; it must not retroactively disguise whether a field or record existed in the source.
This section intentionally stops at exact ordered names. Duplicate header names, header inference, arbitrary dialect discovery, and automatic renaming are separate designs with different ambiguity and evidence requirements.
Prove that a late bad record is not partly published
C8 contains two valid data records followed by r3,short. The first two records parse at width three and enter the private staged list. The third parses at width two, so the gate raises WIDTH with record=3, physical_end_line=4, and staged_count=2. At the caller boundary, published remains None.
That combination is stronger evidence than either fact alone. staged_count=2 proves the failure was late rather than immediate. published=None proves this helper did not return the valid prefix as an accepted file. A log that reported only “two rows processed” could be mistaken for a partial success; a log that reported only “width error” would hide how far the parser progressed.
C9 is the positive control. It contains two complete records, including C4’s explicitly empty third field. Both are staged, end-of-input is reached, and the returned list contains two mappings. The contrast demonstrates that publication depends on whole-file success, not merely a nonempty prefix.
In a real sink, preserve the same semantic boundary with an appropriate mechanism: write to a temporary object and promote it, load into an isolated staging table and publish through a separate approved step, or buffer messages until file admission is complete. The mechanism depends on the sink. This in-memory fixture does not claim crash safety, database rollback, distributed atomicity, or exactly-once delivery.
Keep recovery tied to retained source bytes
A staged count cannot reconstruct rejected data. It cannot tell whether the missing third field was accidentally omitted, whether a producer changed schema, or whether the source object itself was incomplete. Nor can a downstream dictionary recover a discarded fourth field after zip() truncation.
Recovery should start from the preserved source identified by its digest. Obtain a corrected producer export or approve a revised contract, create a new run record, and parse the complete source again. Never synthesize a third value from a neighboring row or release C8’s prefix merely because those two mappings looked valid in isolation.
Reconcile syntax, width and publication evidence
The supplied manifest and execution agree on all twelve cases. “PASS” means the observed classification matched the expected diagnostic outcome; it does not mean every file was admitted. This is the same evidence discipline encouraged by repeatable QA fixtures: deterministic inputs, explicit negative controls, and assertions tied to the decision that matters.
Case | Input characteristic | Expected | Observed | Publication outcome |
C1 | Ordinary three-field record | ACCEPT | ACCEPT | One mapping returned |
C2 | Missing third field | WIDTH | WIDTH | None; DictReader used value=None |
C3 | Extra fourth field | WIDTH | WIDTH | None; DictReader used None: ['tail'] |
C4 | Explicitly empty third field | ACCEPT | ACCEPT | One mapping with value="" |
C5 | Quoted comma in label | ACCEPT | ACCEPT | One three-field mapping |
C6 | Quoted embedded CRLF | ACCEPT | ACCEPT | One mapping; physical_end_line=3 |
C7 | Unterminated quoted field | SYNTAX | SYNTAX | None |
C8 | Two valid rows, then short row | WIDTH | WIDTH | None; staged_count=2 |
C9 | Two complete rows | ACCEPT | ACCEPT | Two mappings returned |
C10 | Reordered header | HEADER | HEADER | None |
C11 | Blank record after header | WIDTH | WIDTH | None; DictReader returned no row |
C12 | Complete final row, no trailing newline | ACCEPT | ACCEPT | One mapping returned |
A successful list(reader), a dictionary with the expected keys, or a nonempty output is insufficient. The evidence must reconcile parser completion, exact header, exact row width, and publication state for the same source identity.
Choose accept, reject or hold without quiet repair
Translate the laboratory’s compact reasons into operational decisions without changing their meaning:
Condition | Decision | Required evidence |
Exact header; complete parse; every data row has width three; empty-field policy satisfied | ACCEPT_FILE | Source identity, configuration, complete staged output, no unresolved checks |
Parsed row has width other than three | REJECT_WIDTH | Record ordinal, physical end line, staged count, retained source |
Parser raises csv.Error | REJECT_SYNTAX | Exception class, reached context, retained complete source |
Header differs from id,label,value in value or order | REJECT_HEADER | Parsed header, expected header, source identity |
Source bytes, provenance, dialect identity, or empty/missing policy is unresolved | HOLD | Visible statement of what is unknown and who must decide |
HOLD is not a softer form of acceptance. It is the correct outcome when the system cannot establish which bytes were evaluated, whether the producer used the declared dialect, or whether an empty value is allowed. Rerunning unknown inputs repeatedly does not resolve an admission-policy question.
Likewise, a rejected control can be a successful test. C2 is PASS in the fixture because REJECT_WIDTH is the desired technical behavior. Calling that source file “accepted” would confuse validator correctness with data admission.
Do not turn parser repair into business-data invention
The gate must not truncate C3, fill C2, skip C7, ignore C11, or reorder C10 merely to produce three named values. Each action changes source meaning or population:
Truncation discards an observed field.
Padding creates a field that was absent.
Skipping changes the released record set.
Renaming or reordering changes the mapping contract.
Quote repair guesses where a malformed record should end.
A business owner may later approve one of those policies for a defined producer and source version. That approval should produce a new, versioned transformation with before-and-after evidence. It must not be hidden inside “strict CSV parsing.”
Assign producer, parser and downstream responsibilities
The producer owns the exported schema and the dialect it claims to emit. When that contract changes, the producer should version or communicate the change and supply corrected source bytes when an export is defective.
The ingestion owner owns source identity, decoding, parser configuration, structural admission, rejection evidence, and the publication boundary. That includes proving which exact file was accepted and retaining enough information to reproduce why another file was rejected.
Downstream owners define value semantics after structural admission. They may decide whether an explicitly empty value is meaningful, invalid, nullable, or subject to later enrichment. They do not get to reinterpret a missing source field as an explicit empty string without an approved rule.
Those roles become especially important when accepted batch files later feed downstream streaming integration. A downstream event platform can distribute records, but it cannot restore a fourth field that ingestion silently dropped or prove the identity of source bytes that were never retained.
The handoff artifact should therefore include the accepted source digest, contract version, parser configuration, accepted record count, and decision identity, not only the transformed payload. Downstream teams can then distinguish a value-level defect in their own processing from an upstream admission defect and can identify exactly which files require replay after a correction.
If an earlier parser defect affected released files, preserve the old outputs and evidence, identify trustworthy original sources, correct the gate under version control, and recompute only from those sources. Keep a grammar-policy change separate from a business-value cleaning change so reviewers can tell why the population or values changed.
Strengthen ingestion reviews through data-engineering practice
Strict CSV parsing and exact record structure answer different questions. The parser decides whether quoting, delimiters, escaping, and record boundaries are grammatically usable under a declared dialect. The application decides whether the parsed header and field count satisfy the source contract. A trustworthy ingestion review requires both, plus immutable source identity and an explicit publication boundary.
For broader foundations in pipelines, ETL, batch and streaming ingestion, transformation, storage, and governance, review Refonte Learning’s Data Engineering program. The official page lists a three-month period and a dedication of 12–14 hours per week, and it names data pipelining, ETL, batch and streaming ingestion, transformation, storage services, and governance topics. Confirm current admission and programme terms on the official page; the page does not establish that this exact Python csv laboratory or CPython 3.13.5 is part of the syllabus.
The durable review habit is simple: preserve what arrived, parse according to a named grammar, admit only the structure the application approved, and make every repair a separately evidenced decision.
