Ingesting Messy Files: Encoding, Schema Drift, and Malformed Rows

The checks that belong at the file-reading boundary, before a DataFrame reaches any business-rule validation — encoding, unparseable rows, and columns that quietly changed shape.

Data Quality with Pandas covers detecting bad values — a negative amount, a status outside the known set — inside an already-loaded DataFrame. This article is about the step before that even becomes possible: getting a well-formed DataFrame out of a file at all. A CSV export from a legacy system or a partner's nightly drop fails in ways a business-rule check never sees, because the file never successfully becomes a DataFrame in the first place.

Encoding: the silent corruption#

A file declared as UTF-8 that is actually Windows-1252 or another regional encoding does not usually raise an error — it decodes into the wrong characters, silently, and the corruption is only noticed later when a name or address looks subtly wrong:

Python
import pandas as pd
df = pd.read_csv("export.csv", encoding="utf-8")

If encoding is wrong, pandas.read_csv either raises a UnicodeDecodeError — the better outcome, because it fails loudly — or succeeds with mangled text, which is worse because nothing flags it. When the true encoding is unknown, fail loudly first and decide from there:

Python
try:    df = pd.read_csv("export.csv", encoding="utf-8")except UnicodeDecodeError:    df = pd.read_csv("export.csv", encoding="cp1252")

A byte order mark at the start of a file — common in exports from Windows tools — is a related trap: utf-8 decodes it as a stray character prefixed to the first column name, so df.columns shows 'id' instead of 'id' and every lookup by that name fails. encoding="utf-8-sig" strips the BOM if present and behaves exactly like utf-8 if it is absent, so it is a safe default for files of unknown provenance.

Malformed rows: fail the row, not the whole file#

A file that mostly conforms to RFC 4180 — the closest thing CSV has to a formal specification — can still have a few rows with an extra unescaped comma, an unterminated quote, or a wrong field count. The default behavior of read_csv is to raise and abort the entire read on the first such row:

Python
df = pd.read_csv(    "export.csv",    encoding="utf-8-sig",    on_bad_lines="warn",)

on_bad_lines="warn" skips an unparseable row and prints a warning instead of aborting the whole load; "skip" does the same silently. Neither substitute for actually looking at what was skipped — capture and log the warnings, or read once with on_bad_lines="skip" and once counting total lines in the raw file, so the gap between the two numbers is visible instead of silently discarded.

Skipping a bad row is a data quality decision

on_bad_lines="skip" is a choice to silently drop data, not a neutral default. For a source where a handful of malformed rows out of a million is expected noise, that is often the right call. For a source where every row represents money or a compliance-relevant event, a malformed row usually needs to be quarantined and investigated, not thrown away — the same distinction Data Quality with Pandas draws between an anomaly and an error.

Schema drift: the columns changed and nothing told you#

A source can add a column, remove one, rename one, or reorder them without any announcement. Code that reads columns positionally, or that assumes a column exists without checking, breaks in different ways depending on which of these happened:

Python
EXPECTED_COLUMNS = {"order_id", "customer_id", "amount", "created_at"}
def validate_schema(df: pd.DataFrame) -> None:    actual = set(df.columns)    missing = EXPECTED_COLUMNS - actual    unexpected = actual - EXPECTED_COLUMNS
    if missing:        raise ValueError(f"Missing expected columns: {missing}")    if unexpected:        print(f"New columns not yet handled by this pipeline: {unexpected}")

The two cases are handled differently on purpose. A missing column means downstream code that reads it will fail — better to stop immediately with a clear message than let a KeyError surface three transformations later with no context. A new, unexpected column is not itself dangerous — nothing currently reads it — so it is worth logging rather than halting the pipeline over a column nobody asked for yet.

Selecting columns explicitly, always#

Regardless of what validate_schema finds, downstream code should select columns by name rather than trusting the file's full shape:

Python
df = df[["order_id", "customer_id", "amount", "created_at"]]

Writing a DataFrame back to ClickHouse makes the identical point on the output side — explicit column selection is cheap insurance against a source appending a field your pipeline was never designed to carry forward.

Type coercion after the columns are right#

Even with the right columns present, read_csv infers types from a sample of the data and can guess wrong — an order_id column that is usually numeric but has one row with a stray letter becomes object (string) for the whole column instead of the numeric type every other row would suggest:

Python
df["amount"] = pd.to_numeric(df["amount"], errors="coerce")invalid_amounts = df[df["amount"].isna()]

errors="coerce" turns unparseable values into NaN instead of raising, which lets the load continue while making the bad rows visible in invalid_amounts for the same kind of investigation Data Quality with Pandas covers for business-rule anomalies — the pattern is deliberately the same one, applied one stage earlier.

Common mistakes#

Assuming the declared encoding is correct#

A file's stated encoding — in an email, a filename, an API's Content-Type header — is a claim, not a guarantee. Decode defensively, especially for files from external partners.

Silently skipping malformed rows without counting them#

on_bad_lines="skip" with no logging turns a data-loss event into an invisible one. At minimum, log how many rows were skipped so a silent change in a source's export quality gets noticed.

Reading columns by position#

df.iloc[:, 2] for "the amount column" breaks the moment a source inserts a new column before it. Read and select by name.

Treating a new column the same as a missing one#

A missing expected column is usually a hard failure. A new, unexpected column is usually just information — collapsing the two into one check either raises too often or misses real breakage.

Further reading#

See also#