Fix pandas DtypeWarning Mixed Types
Every read of a particular file prints the same warning, and the frame that comes back behaves oddly afterwards:
DtypeWarning: Columns (7,19) have mixed types. Specify dtype option on import or set low_memory=False.
Then arithmetic fails in a way that makes no sense for a numeric column:
>>> frame["amount"].sum()
TypeError: unsupported operand type(s) for +: 'float' and 'str'
>>> frame["amount"].dtype
dtype('O')
The warning is precise and easy to misread. pandas is not saying the column contains bad data — it is saying that it inferred two different types for the same column in different parts of the file, and had to fall back to object to hold both.
Root Cause
The C parser reads the file in internal blocks and infers each column's dtype per block. When block 1 of amount contains only digits it becomes float64; when block 40 contains "N/A" it becomes object; and to reconcile the two, pandas promotes the whole column to object. The warning names the column positions, not names, which is why it is so often ignored.
The usual culprits are a handful of sentinel tokens introduced by whatever exported the file: N/A, -, NULL, #N/A, TBC, a trailing total row, or a thousands separator that appears only in large values. Any one of them, anywhere in millions of rows, is enough.
Minimal Diagnostic
Turn the positional warning into names and offending values.
# pip install "pandas>=2.2,<3"
from pathlib import Path
import pandas as pd
CSV = Path("data/transactions.csv")
def name_columns(csv_path: Path, positions: list[int]) -> list[str]:
"""Translate the positions in the warning into column names."""
header = pd.read_csv(csv_path, nrows=0)
return [header.columns[i] for i in positions if i < len(header.columns)]
def offending_values(csv_path: Path, column: str, chunk_rows: int = 200_000) -> pd.Series:
"""Every value in a numeric-looking column that will not parse as a number."""
bad = []
for chunk in pd.read_csv(csv_path, usecols=[column], chunksize=chunk_rows, dtype=str):
series = chunk[column]
numeric = pd.to_numeric(series, errors="coerce")
# non-null text that failed to convert is exactly what breaks inference
bad.append(series[numeric.isna() & series.notna()])
found = pd.concat(bad) if bad else pd.Series(dtype=str)
return found.value_counts()
if __name__ == "__main__":
names = name_columns(CSV, [7, 19])
print("warned columns:", names)
for name in names:
print(f"\n{name}:")
print(offending_values(CSV, name).head(10))
warned columns: ['amount', 'settled_on']
amount:
N/A 1284
- 97
1,204.50 12
TOTAL 1
That output is the whole diagnosis: three sentinel spellings and one stray total row. Reading with dtype=str in the diagnostic is deliberate — it stops pandas from converting anything, so nothing is hidden before you look at it.
Fix: Declare the dtype and the Sentinels
Two arguments together. dtype removes the inference; na_values tells the parser which tokens mean "missing" so they become NaN rather than strings.
# pip install "pandas>=2.2,<3"
from pathlib import Path
import pandas as pd
CSV = Path("data/transactions.csv")
SENTINELS = ["N/A", "n/a", "NA", "-", "--", "NULL", "null", "#N/A", "TBC", ""]
def read_clean(csv_path: Path) -> pd.DataFrame:
"""Read with explicit types and an explicit missing-value vocabulary."""
try:
frame = pd.read_csv(
csv_path,
dtype={
"txn_id": "int64",
"account": "category",
"status": "category",
"amount": "float64", # sentinels become NaN, so this now holds
},
na_values=SENTINELS,
keep_default_na=True, # keep pandas' own list as well as these
thousands=",", # "1,204.50" parses as 1204.50
parse_dates=["settled_on"],
)
except ValueError as exc:
# Raised when a declared dtype cannot hold a value the sentinels did not catch
raise ValueError(f"{csv_path}: unconvertible value — run the diagnostic: {exc}") from exc
return frame
if __name__ == "__main__":
frame = read_clean(CSV)
print(frame.dtypes)
print(f"{frame['amount'].isna().sum():,} missing amount(s)")
Declaring dtype changes the failure mode in a way that is entirely the point: an unexpected token now raises ValueError on the row that contains it, instead of quietly promoting the column and surfacing three functions later as a TypeError on a sum. Loud and early beats quiet and late.
Variant Fix 1: Keep the Bad Rows Instead of Coercing Them
errors="coerce" turns anything unparseable into NaN, which is right for sentinels and wrong for values you need to investigate. Split the two populations instead of merging them:
# pip install "pandas>=2.2,<3"
import pandas as pd
def split_unparseable(frame: pd.DataFrame, column: str) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Separate rows whose value will not convert, so they can be reviewed not lost."""
raw = frame[column].astype(str)
numeric = pd.to_numeric(raw.str.replace(",", "", regex=False), errors="coerce")
is_bad = numeric.isna() & raw.str.strip().ne("") & raw.ne("nan")
good = frame.loc[~is_bad].assign(**{column: numeric[~is_bad]})
quarantined = frame.loc[is_bad].assign(_reason=f"{column} not numeric")
return good, quarantined
if __name__ == "__main__":
frame = pd.read_csv("data/transactions.csv", dtype=str)
clean, bad = split_unparseable(frame, "amount")
print(f"{len(clean):,} clean row(s), {len(bad):,} quarantined")
if not bad.empty:
bad.to_csv("out/quarantine_amount.csv", index=False)
Writing the rejects to their own file keeps the pipeline running while preserving the evidence — the general pattern is set out in quarantine invalid rows in a pipeline.
Variant Fix 2: low_memory=False Is Not the Fix
The warning text suggests it, and it does silence the message — by making the parser read the whole file before inferring, which raises peak memory on exactly the files that are already large enough to be a problem.
# Silences the warning, raises memory, and still leaves the column as object
frame = pd.read_csv("data/transactions.csv", low_memory=False)
print(frame["amount"].dtype) # still object if "N/A" is present
Use it only on small files where the warning is noise. On anything large, declare dtype — it is both cheaper and more informative. This matters most inside a chunked read, where each chunk infers independently and the dtypes can differ chunk to chunk; see handling large CSV files with chunking.
Variant Fix 3: Trailing Total or Footer Rows
An export that ends with a TOTAL line puts text in every numeric column of the last row. Skip it at read time rather than filtering after:
# pip install "pandas>=2.2,<3"
import pandas as pd
# Drop a known number of footer lines (needs the Python engine)
frame = pd.read_csv("data/transactions.csv", skipfooter=1, engine="python")
# Or filter on a key column when the footer count varies between files
frame = pd.read_csv("data/transactions.csv", dtype=str)
frame = frame[frame["txn_id"].str.fullmatch(r"\d+", na=False)]
frame["amount"] = pd.to_numeric(frame["amount"].str.replace(",", "", regex=False))
The regular-expression filter is the more robust of the two, because it survives an export whose footer grows a second summary line. Related encoding-level surprises in the same class of file are covered in fixing encoding errors in CSV files.
Verification
Assert the dtypes you asked for actually arrived, and that coercion did not quietly eat live data.
# pip install "pandas>=2.2,<3"
import pandas as pd
EXPECTED = {"txn_id": "int64", "amount": "float64", "status": "category"}
def assert_dtypes(frame: pd.DataFrame, expected: dict[str, str], max_null_pct: float = 5.0) -> None:
"""Fail on a promoted dtype or on suspiciously heavy coercion."""
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}: expected {want}, got {got} (a value did not convert)"
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(s) verified with the expected dtypes")
if __name__ == "__main__":
frame = pd.read_csv("data/transactions.csv", dtype=EXPECTED, na_values=["N/A", "-", "NULL"])
assert_dtypes(frame, EXPECTED)
The null-percentage assertion is the one that catches over-eager sentinel lists. Adding "0" or "None" to na_values by accident turns real values into missing ones, and nothing else in the pipeline will notice. For a stricter, declarative version of the same idea, express the contract as a schema — see validating document data with schemas.
FAQ
Why does the warning name numbers instead of column names?
The message is emitted from the parser before the frame exists, so only positional indexes are available. Map them with pd.read_csv(path, nrows=0).columns, as the diagnostic does.
Can I just ignore it?
Only if the affected column is genuinely text. If it is meant to be numeric or dated, the column is now object: arithmetic breaks, comparisons behave lexicographically, and memory is many times higher than needed.
Does converters= solve it?
It works, but it runs a Python function per value and is therefore very slow on large files. Prefer dtype plus na_values, and keep converters for genuinely custom parsing such as mixed-unit strings.
Why did the warning appear only after I switched to chunked reading?
Chunk boundaries change which values land in which inference block, so a sentinel that used to sit in the same block as other text now sits alone among numbers. The underlying data has not changed — the fix is the same explicit dtype.
Should I use nullable integer dtypes?
Yes, when a numeric column can be missing: Int64 (capital I) holds pd.NA where plain int64 cannot, so you avoid the forced promotion to float that turns identifiers into 417.0.
Related
- Handling Large CSV Files with Chunking — where per-chunk inference bites hardest
- Fix pandas MemoryError Reading a Large CSV — the memory cost of an object column
- Cleaning Messy CSV Data with pandas — sentinel handling and type coercion in depth
- Fixing Encoding Errors in CSV Files — the other class of read-time surprise
- Validating Document Data with Schemas — enforcing dtypes as a contract