This guide is part of Attribute Mapping & Schema Harmonization, within the broader Automated Vector & Raster Cleaning Workflows reference.
Coercing Mixed Attribute Types Across Sources
Twelve municipalities publishing the same attribute will publish it in six different types, and the pipeline has to produce one. The difficulty is not the casting — it is deciding what to do with the values that will not cast, and doing it the same way every night.
Why Blanket Coercion Fails
- Sentinels survive the cast.
-9999becomes a valid integer and poisons every mean computed afterwards. - Identifiers lose information. A parcel code of
00471becomes471and stops joining to anything. - One bad value fails the batch. A strict cast raises on the first offender and tells you nothing about the other 4 000.
- Silent coercion hides drift. A column that quietly becomes a string because one row was text is a schema change nobody was told about.
Version and Environment Compatibility
| Component | Version | Note |
|---|---|---|
| pandas | >=2.2 |
Nullable dtypes: Int64, Float64, string, boolean |
| pyarrow | >=14 |
Backing store for nullable types; Parquet round-trip |
| GeoPandas | >=1.0 |
Attribute frame alongside the geometry column |
pip install "pandas>=2.2" "pyarrow>=14" "geopandas>=1.0"Order Matters: Sentinels First
The coerce_schema Recipe
from __future__ import annotations
import logging
from dataclasses import dataclass, field
import pandas as pd
logger = logging.getLogger(__name__)
GLOBAL_SENTINELS = ("", " ", "NULL", "null", "N/A", "NA", "n/a", "-", "--", "unknown")
@dataclass(frozen=True)
class ColumnRule:
dtype: str # "Int64", "Float64", "string", "boolean", "datetime64[ns]"
sentinels: tuple = () # column-specific extras, e.g. (-9999, -1)
required: bool = False
SCHEMA: dict[str, ColumnRule] = {
"parcel_id": ColumnRule("string", required=True), # a label, never a number
"zone_code": ColumnRule("string", required=True),
"area_sqm": ColumnRule("Float64", sentinels=(-9999, -1)),
"built_year": ColumnRule("Int64", sentinels=(0, 9999)),
"is_protected": ColumnRule("boolean"),
"surveyed_at": ColumnRule("datetime64[ns]", sentinels=("0000-00-00",)),
}
def coerce_schema(df: pd.DataFrame, schema: dict[str, ColumnRule] = SCHEMA,
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Coerce a frame to the target schema, returning (typed, failures)."""
out = df.copy()
failures: list[dict] = []
for column, rule in schema.items():
if column not in out.columns:
if rule.required:
raise ValueError(f"required column {column!r} missing from the batch")
out[column] = pd.Series(pd.NA, index=out.index, dtype=rule.dtype)
continue
series = out[column]
# 1. Sentinels become NA before any cast is attempted.
blanked = series.replace(list(GLOBAL_SENTINELS) + list(rule.sentinels), pd.NA)
# 2. Cast, capturing rather than raising on the values that will not convert.
if rule.dtype.startswith(("Int", "Float")):
converted = pd.to_numeric(blanked, errors="coerce")
elif rule.dtype.startswith("datetime"):
converted = pd.to_datetime(blanked, errors="coerce", utc=False)
else:
converted = blanked
newly_null = converted.isna() & blanked.notna()
for index in out.index[newly_null]:
failures.append({
"row_index": index,
"column": column,
"value": blanked.loc[index],
"target_dtype": rule.dtype,
})
out[column] = converted.astype(rule.dtype)
failure_frame = pd.DataFrame(failures)
if not failure_frame.empty:
by_column = failure_frame.groupby("column").size().to_dict()
logger.warning("coercion failures by column: %s", by_column)
return out, failure_frameKey Implementation Notes
- Sentinels are replaced before the cast, per column.
-1means missing in a survey-year column and is a legitimate value in an elevation delta, so the list belongs on the rule rather than in a global. errors="coerce"captures rather than raises. One unparseable value becomes a null and a failure row; the other 4 000 values still convert, and the batch still completes.- Failures are diffed against the pre-cast nulls. Only values that were present and became null count as failures — otherwise every genuine missing value would be reported.
- Nullable dtypes throughout.
Int64rather thanint64means a missing integer is representable without silently promoting the column to float. - Identifiers are declared as strings in the schema. This is the single most valuable line in the table and the one most often omitted.
- A missing required column raises immediately. That is a structural failure, not a per-row one, and continuing produces a batch that validates against a schema it does not meet.
Booleans and Dates, the Two Worst Offenders
Boolean columns arrive as Y/N, yes/no, T/F, 1/0, true/false and occasionally -1/0, sometimes several within one dataset. A generic cast maps almost none of them correctly, and bool("N") is True, which is the worst possible failure because it is silent and inverted. Map booleans through an explicit dictionary per source and treat an unmapped value as a coercion failure rather than as False.
Dates are worse because they are ambiguous rather than merely varied. 03/04/2026 is two different days depending on the publisher’s locale, and no amount of parsing sophistication resolves it from the value alone. Where a source’s convention is known, state the format explicitly and parse strictly; where it is not, the only correct move is to ask, because a pipeline that guesses will guess consistently and be wrong for half the year.
Troubleshooting Coercion
| Symptom | Likely cause | Fix |
|---|---|---|
| Statistics dragged far from reality | Sentinel cast to a number | Normalise sentinels before casting |
| Identifiers stop joining | Numeric cast dropped leading zeros | Declare identifier columns as string |
| Column silently became object | One text value in a numeric column | Coerce with capture; inspect the failure rows |
| Every value in a column fails | Wrong target type for the column’s real content | Change the rule, not the data |
| All booleans are True | Truthiness of a non-empty string | Map booleans explicitly per source |
| Dates off by months | Ambiguous day/month order | Parse with an explicit format per source |
Integration Note
Coercion runs in the harmonization stage, after column names are mapped and before validation — the ordering set out in attribute mapping and schema harmonization. The failure frame joins to the row identifiers and goes to the quarantine path in quarantining invalid features to a dead-letter table, and the per-column failure rate belongs in the metrics as a leading indicator of upstream schema drift.
Units, the Coercion Nobody Writes Down
Type coercion converts "12.4" to 12.4. It says nothing about whether that number is metres, feet or square metres, and mixing units across sources is a harder failure to detect than mixing types because the result is always numerically valid.
Three habits keep it visible. Put the unit in the column name — area_sqm, frontage_m, elevation_ft_asl — so that a mismatch is apparent at the point of use rather than in a schema document. Record the source’s declared unit per column in the mapping registry, next to the alias, so that a conversion factor is data rather than a magic number in code. And assert on plausible ranges after conversion: a residential parcel of 40 000 square metres is possible and a parcel of 40 000 square feet is more likely, and a range check catches the confusion that a type check cannot.
Where a source publishes units inconsistently between releases — which happens more often than it should — the range assertion is the only thing standing between the pipeline and a silent factor-of-ten error in every downstream figure.