Handling Malformed Rows and Encoding Errors in CSV Feeds Permalink to this section
↑ Part of Parsing CSV and Excel Feeds with Pandas.
A supplier CSV that parsed cleanly for six months can break on any given Tuesday: a warehouse clerk pastes a free-text note containing an unescaped comma, an ERP export rotates from cp1252 to utf-8 mid-quarter without telling anyone, or a spreadsheet macro appends a trailing summary row with three extra columns. Left to defaults, pandas either raises pandas.errors.ParserError and kills the entire batch, or — worse — silently drops the offending rows under on_bad_lines="skip" with no record of what was lost. Neither outcome is acceptable in a reconciliation pipeline where every row is a financial or inventory fact. The fix is a detect-then-quarantine read: isolate encoding problems before tokenizing, capture every malformed row instead of discarding it, and route both into a structured, reprocessable queue.
Operational Trigger Signals Permalink to this section
Add malformed-row handling to a feed’s parser when its ingestion logs show any of the following. Each signal is drawn directly from pandas’ own exception surface, so detection requires no custom heuristics:
pandas.errors.ParserError/ tokenizing failures: the C engine raisesError tokenizing data. C error: Expected N fields in line M, saw Kwhen a row has more or fewer comma-separated fields than the header. This almost always means an unescaped delimiter or a stray trailing row leaked into the file.UnicodeDecodeErroron the declared encoding: a read that assumesutf-8throws'utf-8' codec can't decode byte 0x92 in position Nagainst acp1252-exported file, because Windows smart quotes and em-dashes occupy byte ranges that are invalid UTF-8 continuation bytes.- A residual byte-order mark (BOM) on the first header: the first column name reads as
skuinstead ofsku, silently breaking everydf["sku"]lookup and header-keyed join even though the read itself did not raise. - Embedded delimiters or unescaped quotes inside free-text fields: a shipping-notes or vendor-address column containing a literal comma or an unbalanced
"shifts every subsequent column in that row, producing values that parse without error but land in the wrong column — the most dangerous failure mode because nothing raises.
When any of these show up at more than a token rate, treat it as a parser-configuration gap, not a one-off bad file. Compute the row-level reject rate for the batch:
A single supplier crossing on consecutive runs means the source template has drifted and needs the two-pass handling below rather than a one-time manual fix.
Step-by-Step Implementation Permalink to this section
Treat every ingest as two passes over the same file rather than one. The first pass only answers “can this byte stream be decoded,” the second only answers “does this row tokenize to the expected shape.” Neither pass drops data — both write to a typed record instead of raiseing past it.
- Probe candidate encodings in strict mode — attempt a full, non-substituting decode against an ordered list (
utf-8-sig,cp1252,latin-1) and stop at the first one that succeeds without a single substitution. - Read with the Python engine and a callable
on_bad_lines— the C engine cannot run custom row handlers; the Python engine accepts a function that receives each ragged row’s raw fields and decides its fate. - Capture, don’t discard, every ragged row — inside that callable, append the raw fields, the observed field count, and a reason code to a quarantine buffer, then return
Noneso pandas excludes it from the clean frame without raising. - Respect quoting on the clean pass — set
quotingandquotecharexplicitly so a legitimately comma-containing, properly quoted field is never mistaken for a ragged row. - Emit the reject rate and persist the quarantine buffer — log for the batch and write the buffer to a queryable quarantine table before the clean frame moves on to schema validation.
import csv
import logging
from pathlib import Path
from typing import List, Optional, Tuple
import pandas as pd
logger = logging.getLogger("ingestion.csv_quarantine")
ENCODING_CANDIDATES: List[str] = ["utf-8-sig", "cp1252", "latin-1"]
def detect_encoding(file_path: Path, candidates: List[str] = ENCODING_CANDIDATES) -> str:
"""Try candidate encodings in strict mode until one decodes the whole file.
Strict mode means zero substitutions are tolerated: a codec that would
silently replace an unmappable byte is rejected here rather than left to
corrupt a vendor name three stages downstream.
"""
for encoding in candidates:
try:
with file_path.open("r", encoding=encoding, errors="strict") as handle:
handle.read()
logger.info("encoding_detected file=%s encoding=%s", file_path.name, encoding)
return encoding
except UnicodeDecodeError as exc:
logger.warning(
"encoding_rejected file=%s encoding=%s error=%s", file_path.name, encoding, exc
)
continue
raise UnicodeDecodeError(
candidates[-1], b"", 0, 1, f"no candidate encoding decoded {file_path.name}"
)
def parse_csv_with_quarantine(
file_path: Path,
expected_field_count: int,
encoding_candidates: List[str] = ENCODING_CANDIDATES,
) -> Tuple[pd.DataFrame, pd.DataFrame]:
"""Two-pass CSV read that never drops a row without a record.
Pass 1 (detect_encoding) finds a codec that decodes the file cleanly.
Pass 2 parses with the Python engine and a callable on_bad_lines handler
that captures every ragged row instead of raising ParserError or
skipping it silently. Returns (clean_df, quarantine_df).
"""
encoding = detect_encoding(file_path, encoding_candidates)
quarantined: List[dict] = []
def capture_bad_line(bad_line: List[str]) -> Optional[List[str]]:
# pandas calls this once per row whose field count does not match the
# header. Returning None excludes the row from clean_df; we log the
# raw fields first so nothing is lost, only deferred.
quarantined.append(
{
"raw_fields": bad_line,
"field_count": len(bad_line),
"expected_field_count": expected_field_count,
"reason": "FIELD_COUNT_MISMATCH",
}
)
logger.warning(
"ragged_row file=%s field_count=%d expected=%d",
file_path.name, len(bad_line), expected_field_count,
)
return None
clean_df = pd.read_csv(
file_path,
encoding=encoding,
encoding_errors="strict", # never let a decode error smear into data
engine="python", # required: callable on_bad_lines is python-only
sep=None, # sniff the delimiter for inconsistent legacy exports
quoting=csv.QUOTE_MINIMAL, # respect quoted embedded delimiters
on_bad_lines=capture_bad_line,
)
quarantine_df = pd.DataFrame(quarantined)
total = len(clean_df) + len(quarantine_df)
if not quarantine_df.empty and total:
quarantine_df["file_name"] = file_path.name
quarantine_df["detected_encoding"] = encoding
reject_rate = len(quarantine_df) / total
logger.error(
"quarantine_summary file=%s rows_quarantined=%d reject_rate=%.4f",
file_path.name, len(quarantine_df), reject_rate,
)
return clean_df, quarantine_df
Field-count mismatches are the mechanical failure the callable catches, but embedded-delimiter corruption that does tokenize — a comma inside an unquoted address field shifting every later column — will not raise anything. That class of error is only caught downstream, once typed fields fail validation against the contract defined in Schema Validation Using Pydantic; route those rejections into the same quarantine table using a SCHEMA_INVALID reason rather than a separate queue.
Configuration Reference Permalink to this section
These read_csv parameters are the surface that determines whether a malformed row raises, vanishes, or lands in the quarantine table. Set them explicitly per feed rather than relying on pandas’ defaults, which favor silent leniency over auditability.
| Parameter | Accepted values | Recommended | Notes |
|---|---|---|---|
encoding |
any codec name | first hit from ["utf-8-sig", "cp1252", "latin-1"] |
Probe in strict mode first; never assume a single fixed encoding per feed |
encoding_errors |
strict, replace, ignore |
strict |
strict fails the decode loudly in pass 1 instead of substituting � into a vendor name |
engine |
c, python, pyarrow |
python |
A callable on_bad_lines handler is only supported by the Python engine |
on_bad_lines |
error, warn, skip, callable |
callable (capture_bad_line) |
Callable is the only mode that preserves the raw fields of a rejected row |
sep |
explicit character, None |
None |
None triggers the Python engine’s delimiter sniffer for inconsistent legacy exports |
quoting |
csv.QUOTE_MINIMAL / ALL / NONE / NONNUMERIC |
QUOTE_MINIMAL |
Prevents a properly quoted, comma-containing field from being flagged as ragged |
quotechar |
any single character | " |
Must match the supplier’s actual quote character; some legacy exports use ' |
escapechar |
single character or None |
None unless required |
Needed only when a field contains an unescaped quote inside quoted text |
dtype |
mapping | str for identifiers |
Applied after quarantine routing so a bad row never forces a column-wide float cast |
Debugging & Recovery Permalink to this section
A quarantined row is a deferred decision, not a lost one. Give it a schema that supports both root-cause analysis and safe replay:
- Quarantine table schema — persist
quarantine_id,file_name,content_hash,line_no,raw_fields(stored as a JSON array to preserve field boundaries exactly as tokenized),field_count,expected_field_count,reason,detected_encoding,quarantined_at,resolved_at(nullable), andreprocess_attempt_id. Thecontent_hashplusline_nopair is what lets a fix target one row without re-touching the whole file. - Closed reason taxonomy — restrict
reasontoENCODING_ERROR,FIELD_COUNT_MISMATCH,UNESCAPED_QUOTE,DELIMITER_AMBIGUOUS, andSCHEMA_INVALID(the last assigned downstream by the Pydantic layer, not the parser). A closed set turns the table into a triage dashboard instead of a text dump. - Reprocessing loop — never mutate quarantined rows in place. Correct the source template or extend the encoding candidate list, then re-run
parse_csv_with_quarantineagainst the same file under a newreprocess_attempt_id, soresolved_atand the full attempt history stay intact for audit. - Escalation threshold — if a row survives three reprocess attempts unresolved, or a single file’s stays above the 0.5% trigger for three consecutive runs, stop patching it ad hoc and route the batch into the intake and priority scoring described in Exception Queue Design and Triage — malformed-row backlog is a materiality and staffing problem at that point, not a parsing bug.
- Never widen leniency to clear a backlog — switching
encoding_errorstoreplaceoron_bad_linesto"skip"to make a queue shrink converts an auditable defect into silent, unrecoverable data loss. Fix the source or the reason taxonomy, not the strictness.
FAQ Permalink to this section
Why does pandas raise a ParserError instead of just skipping a ragged row? Permalink to this section
Because the default on_bad_lines="error" treats any field-count mismatch as fatal to the whole read, on the assumption that a shape error might indicate the delimiter or encoding was wrong for the entire file, not just one row. That default is safe but blunt — it stalls a batch over a single bad line. Overriding on_bad_lines with a callable keeps the fail-loud default for genuinely structural problems while letting individual ragged rows fall through to quarantine instead of aborting everything after them.
My file parses fine locally but raises UnicodeDecodeError in production — why? Permalink to this section
The two environments are almost certainly assuming different default encodings, or the production file genuinely came from a different export path than the one you tested with. Never rely on a platform’s implicit default codec; always pass an explicit encoding and probe a short ordered candidate list in strict mode before the real parse, exactly as detect_encoding does above. That removes the environment as a variable and makes the failure reproducible from the file alone.
Should I just set encoding_errors=“replace” to make the decode errors go away? Permalink to this section
No. replace substitutes � for every byte it cannot map, which lets the read succeed while quietly corrupting vendor names, addresses, and any free-text field that touched the bad byte range — and that corruption then propagates into fuzzy-matching keys with no error to catch it. Keep encoding_errors="strict" in the detection pass so a mismatched codec fails immediately and visibly, and only widen it, file by file, once you have confirmed the correct encoding rather than papering over the wrong one.
Related Permalink to this section
- Parsing CSV and Excel Feeds with Pandas — the parser-selection and type-coercion contract this page’s quarantine step feeds into
- Step-by-Step Guide to Parsing Large CSV Feeds in Pandas — chunked reads for feeds too large to hold in memory
- Schema Validation Using Pydantic — where structurally clean but semantically invalid rows are caught next
- Exception Queue Design and Triage — where a persistent quarantine backlog escalates for prioritized review
- ↑ Parent: Parsing CSV and Excel Feeds with Pandas