Fix pandera SchemaError Dtype Mismatch
Validation fails before any of the value checks run:
SchemaError: expected series 'net_amount' to have type float64, got object
SchemaError: expected series 'issued_on' to have type datetime64[ns], got object
SchemaError: expected series 'invoice_no' to have type int64, got float64
The third message is the interesting one — nothing in the data is textual, yet the type is wrong. All three come from the same place: pandas decided the column's dtype while reading, and that decision did not match the contract.
Root Cause
pandas infers a dtype per column from the values it sees. One value that will not parse as a number promotes the whole column to object; one missing value in an integer column promotes it to float64, because plain int64 has no way to represent a null.
pandera then compares the actual dtype against the declared one. By default it does not convert anything, so the mismatch is reported as-is and no field checks run — which is why a single bad cell produces an error message that says nothing about that cell.
Minimal Diagnostic
The error names a column. Find the values in it that blocked inference.
# pip install "pandas>=2.2,<3"
from pathlib import Path
import pandas as pd
CSV = Path("data/extracted_invoices.csv")
def blockers(csv_path: Path, column: str, target: str = "numeric") -> pd.Series:
"""Values in a column that will not convert to the target type."""
if not csv_path.exists():
raise FileNotFoundError(f"No such file: {csv_path}")
series = pd.read_csv(csv_path, usecols=[column], dtype=str)[column]
if target == "numeric":
converted = pd.to_numeric(series, errors="coerce")
elif target == "datetime":
converted = pd.to_datetime(series, errors="coerce", format="mixed")
else:
raise ValueError(f"Unknown target: {target}")
failed = series[converted.isna() & series.notna()]
return failed.value_counts()
if __name__ == "__main__":
print(blockers(CSV, "net_amount"))
print(blockers(CSV, "issued_on", target="datetime"))
N/A 14
1,204.50 3
£98.00 1
Name: net_amount, dtype: int64
Three distinct causes in one column: a sentinel, a thousands separator, and a currency symbol. Each needs a different treatment, and none of them is a schema problem.
The Fix: Clean at the Read, Coerce in the Schema
Handle what the reader can handle, then let pandera coerce the rest.
# pip install "pandas>=2.2,<3" "pandera>=0.20,<0.23"
from pathlib import Path
import pandas as pd
import pandera.pandas as pa
SENTINELS = ["N/A", "n/a", "NA", "-", "--", "NULL", "null", "#N/A", ""]
def read_typed(csv_path: Path) -> pd.DataFrame:
"""Read with the sentinels and separators the source actually uses."""
frame = pd.read_csv(
csv_path,
dtype={"invoice_no": str}, # identifiers stay text: no float promotion
na_values=SENTINELS, # sentinels become NaN, not strings
thousands=",", # "1,204.50" parses as a number
)
if "net_amount" in frame.columns and frame["net_amount"].dtype == object:
frame["net_amount"] = pd.to_numeric(
frame["net_amount"].astype(str).str.replace(r"[£$€\s]", "", regex=True),
errors="coerce",
)
if "issued_on" in frame.columns:
frame["issued_on"] = pd.to_datetime(frame["issued_on"], errors="coerce", format="mixed")
return frame
class InvoiceSchema(pa.DataFrameModel):
invoice_no: pa.typing.Series[str] = pa.Field(str_matches=r"^\d{6}$")
net_amount: pa.typing.Series[float] = pa.Field(ge=0, nullable=True)
issued_on: pa.typing.Series[pd.Timestamp] = pa.Field(nullable=True)
class Config:
coerce = True # convert where lossless; fail loudly where not
strict = True
if __name__ == "__main__":
frame = read_typed(Path("data/extracted_invoices.csv"))
validated = InvoiceSchema.validate(frame, lazy=True)
print(validated.dtypes)
coerce=True is the single most useful setting for this error class. With it, a column of numeric strings becomes float64 and validation proceeds; without it, the same column fails on type before any range check runs. Coercion still refuses the impossible — "1O4.5O" fails rather than silently becoming null — which is exactly the boundary you want.
Variant Fix 1: Integers That Became Floats
invoice_no reading as float64 means the column contains a null. Two answers, and the choice matters.
# pip install "pandas>=2.2,<3" "pandera>=0.20,<0.23"
import pandas as pd
import pandera.pandas as pa
# Option A — identifiers are labels, not arithmetic: keep them as strings
frame["invoice_no"] = frame["invoice_no"].astype("string")
# Option B — genuinely numeric and nullable: use the nullable integer dtype
frame["quantity"] = frame["quantity"].astype("Int64") # capital I
class Schema(pa.DataFrameModel):
invoice_no: pa.typing.Series[pd.StringDtype] = pa.Field(str_matches=r"^\d{6}$")
quantity: pa.typing.Series[pd.Int64Dtype] = pa.Field(ge=0, nullable=True)
class Config:
coerce = True
Option A is right far more often than people expect. An invoice number, an account code and a postcode are identifiers: leading zeros are meaningful, arithmetic on them is not, and reading them as numbers is what turns 00417 into 417.0 — the same class of loss described in working with Excel dates and number formats.
Variant Fix 2: Dates That Will Not Parse
A mixed-format date column needs parsing in passes rather than one guess.
# pip install "pandas>=2.2,<3"
import pandas as pd
def parse_dates_multi(series: pd.Series, formats: list[str]) -> pd.Series:
"""Apply each format in turn to the values still unparsed."""
result = pd.Series(pd.NaT, index=series.index, dtype="datetime64[ns]")
for fmt in formats:
pending = result.isna() & series.notna()
if not pending.any():
break
result.loc[pending] = pd.to_datetime(series[pending], format=fmt, errors="coerce")
unparsed = series[result.isna() & series.notna()]
if len(unparsed):
print(f"{len(unparsed)} date(s) matched no format, e.g. {unparsed.unique()[:3]}")
return result
if __name__ == "__main__":
frame["issued_on"] = parse_dates_multi(frame["issued_on"],
["%Y-%m-%d", "%d/%m/%Y", "%d-%b-%Y"])
Reporting the leftovers rather than silently coercing them to NaT is the difference between a fix and a cover-up: a batch where 30% of dates match no format is a template change, not a data-quality blip.
Variant Fix 3: Timezone-Aware Against Naive
A column parsed with utc=True is datetime64[ns, UTC], which does not match a schema declaring datetime64[ns]:
SchemaError: expected series 'issued_on' to have type datetime64[ns], got datetime64[ns, UTC]
Pick one convention for the whole pipeline and enforce it in the schema:
# pip install "pandas>=2.2,<3" "pandera>=0.20,<0.23"
import pandas as pd
import pandera.pandas as pa
# Convention: everything is stored as UTC-aware
frame["issued_on"] = pd.to_datetime(frame["issued_on"], utc=True, errors="coerce")
class Schema(pa.DataFrameModel):
issued_on: pa.typing.Series[pd.DatetimeTZDtype] = pa.Field(dtype_kwargs={"unit": "ns", "tz": "UTC"})
Mixing the two is what produces comparisons that raise mid-pipeline, long after validation passed.
Verification
Assert the dtypes after reading and after validating, and check that coercion did not quietly null out live data.
# pip install "pandas>=2.2,<3" "pandera>=0.20,<0.23"
import pandas as pd
EXPECTED = {"invoice_no": "string", "net_amount": "float64", "issued_on": "datetime64[ns]"}
def assert_dtypes_and_coverage(frame: pd.DataFrame, expected: dict[str, str],
max_null_pct: float = 5.0) -> None:
"""Fail on a wrong dtype, or on coercion that removed too many values."""
for column, want in expected.items():
assert column in frame.columns, f"missing column: {column}"
got = str(frame[column].dtype)
assert got == want, f"{column}: dtype is {got}, expected {want}"
for column in expected:
null_pct = frame[column].isna().mean() * 100
assert null_pct <= max_null_pct, (
f"{column}: {null_pct:.1f}% null after parsing — coercion is eating real values"
)
print(f"{len(expected)} column dtype(s) and coverage verified")
if __name__ == "__main__":
frame = read_typed(Path("data/extracted_invoices.csv"))
assert_dtypes_and_coverage(frame, EXPECTED)
InvoiceSchema.validate(frame, lazy=True)
The coverage half is what stops the classic over-correction: adding so many entries to na_values that genuine values become null, so the dtype check passes while the data quietly empties. Run both assertions in CI against a fixture file, and pair the schema with the reject handling in quarantine invalid rows in a pipeline.
FAQ
Should I set coerce=True globally or per field?
Globally in Config for extracted data, where almost everything arrives as text. Per field when one column has a deliberate type you do not want converted.
Why does the error appear on a column that looks fine? Because one value out of a million is enough. The diagnostic prints the offenders with counts, which is faster than scanning the file.
Does coerce change my data?
It converts representation, not meaning: "1204.50" to 1204.50, "2026-07-15" to a timestamp. Anything it cannot convert losslessly raises rather than becoming null.
Can I declare object and check the values instead?
You can, and you lose the guarantee that downstream arithmetic works. Keep the real dtype and fix the read; object should be a deliberate choice for genuinely free-text columns only.
Why did the dtype differ between two runs of the same job? Different input files, or chunked reading with per-chunk inference. Declaring dtypes at the read removes both sources of variation — see fix pandas DtypeWarning mixed types.
Where the Dtype Should Be Decided
There are three places a column's type can be established, and picking one deliberately removes most of these errors permanently.
At the reader is the earliest and usually the best: dtype= and na_values= on read_csv or read_excel mean the frame is correctly typed the moment it exists, and any value that cannot conform fails with the file name and row still in scope. The cost is that the reader call grows a dictionary that has to be maintained alongside the source format.
In the schema is the next best, using coerce=True. It keeps the type declaration in one place with the range and pattern checks, which is easier to review as a whole. The cost is that the failure happens one step later, so the error message names the column rather than the file.
In ad-hoc code between the two is the worst option, and the most common. Scattered astype and to_numeric calls drift apart, and the type a column ends up with depends on which branch ran. If you find yourself adding a third conversion for the same column, that is the signal to move the decision into the reader and delete the others.
Related
- Validating Document Data with Schemas — the schema workflow this error belongs to
- Quarantine Invalid Rows in a Pipeline — what to do with rows that fail
- Fix pandas DtypeWarning Mixed Types — the read-time warning behind most of these
- Cleaning Messy CSV Data with pandas — sentinel and coercion patterns
- Working with Excel Dates and Number Formats — date columns that arrive as serials or text