数据清洗后检查字段类型,核心不是看 dtype 那行输出好不好看,而是确认「声明的类型」和「列里实际承载的值」是否还对得上。清洗过程里只要做过去空格、空值填充、拼接、格式转换或导出再导入,类型就可能被悄悄改掉:数字列混进了空串,日期列一半是 2024/01/02 一半是 2024-01-02,ID 列的前导零被读成整数后消失。判断方向是:先拿到期望类型清单,再对每一列做值级别的可转换性检查,最后用目标端写入和抽样对照来收口。
检查字段类型的可行路径是三步对照:期望 schema(建表语句或数据字典)、清洗后全量 dtype 快照、逐列值级可转换率。判断一项是否有问题,看三件事——非空值能否整体转成目标类型、转换失败的行能否逐条解释、极端值(最大最小、最长最短)是否落在业务区间。适用前提是手上有一份明确的期望类型定义;如果只有清洗后的文件而没有任何目标 schema,先补定义再谈检查,否则查出来的只是读取端推断结果,不能作为结论。
先确认当前场景和目标
动手之前要先确认清洗后的数据往哪里落地,因为落地方式的约束强弱不同,检查重点也不同。
- 落进强类型库(数仓表、关系库):类型由建表语句决定,清洗结果里不合规的值会在写入时报错或被隐式转换。检查重点是写入前的可转换率和失败样例。
- 落成 CSV、Parquet 等文件:文件本身约束弱,类型靠读取端推断。检查重点是读取参数的稳定性,比如
dtype=str读进来再自己转,避免依赖默认推断。 - 作为模型或报表输入:关注的是数值列不能有字符串、日期列要能排序、分类列取值集合是否稳定。
还需要先分清清洗动作改了什么。如果只是过滤行、没有改值,类型问题基本来自源头,排查源头即可;如果做过类型转换、去空格、空值填充、字符串拼接,就必须重新校验,因为这些动作最容易把空串和 NULL 混为一谈,或者把数字变成带说明文字的新列。判断条件可以先用一句话写下来:这次清洗后,每个字段的期望类型、是否允许为空、取值约束分别是什么。写不出来说明缺数据字典,需要先补。
按这个顺序处理
建议按「定标准 → 看快照 → 查值 → 出清单」的顺序走,避免一上来就盯着 dtype 猜。
- 整理期望类型清单:从建表语句、schema 文件或数据字典里抄出字段名、期望类型、可空性、约束(如金额两位小数、ID 定长)。这是后面所有比对的基准。
- 取全量 dtype 快照:pandas 用
df.dtypes,SQL 侧查information_schema.columns,Parquet 读 schema 元数据。快照要和期望清单逐字段对照,标出不一致项。 - 做逐列值级探查:dtype 一致不代表值干净,比如 object 列里全是数字字符串。要看非空数、去重数、长度分布,以及「强制转成目标类型后有多少变成空」。
- 盯高发陷阱字段:数字列里的
''、'NULL'、'N/A'、千分位逗号;ID 类字段的前导零;日期列的多种分隔符和时区;布尔列的Y/N、1/0、是/否混用;金额用浮点存导致小数抖动。 - 产出一张问题清单:字段名、问题类型、样例值、影响行数(用 count 算出来,不要估)。清单里每一项都要能对应一个修改动作或一个「确认可接受」的判断。
下面是一个 pandas 的检查骨架,用途是先把所有列按字符串读入,再逐列试转,避免读取阶段就把问题掩盖掉:
import pandas as pd
# 全部按字符串读入,保留原始形态
df = pd.read_csv("cleaned.csv", dtype=str, keep_default_na=False)
expected = {
"order_id": "string",
"amount": "float",
"created_at": "datetime",
"is_paid": "bool",
}
for col, target in expected.items():
s = df[col]
if target in ("float", "int"):
conv = pd.to_numeric(s, errors="coerce")
bad = s[(conv.isna()) & (s.str.strip() != "")]
print(col, "转数字失败行数:", len(bad), "样例:", bad.head(5).tolist())
elif target == "datetime":
conv = pd.to_datetime(s, errors="coerce")
bad = s[(conv.isna()) & (s.str.strip() != "")]
print(col, "转日期失败行数:", len(bad), "样例:", bad.head(5).tolist())
elif target == "bool":
print(col, "取值分布:", s.value_counts().head(10).to_dict())
elif target == "string":
print(col, "长度极值:", s.str.len().min(), s.str.len().max())
如果数据落在库里,可以用 TRY_CAST 做同样的检查,把失败行数一次算出来:

SELECT
COUNT(*) AS total_rows,
SUM(CASE WHEN TRY_CAST(amount AS DECIMAL(18,2)) IS NULL
AND amount IS NOT NULL AND TRIM(amount) <> '' THEN 1 ELSE 0 END) AS amount_bad,
SUM(CASE WHEN TRY_CAST(created_at AS TIMESTAMP) IS NULL
AND created_at IS NOT NULL AND TRIM(created_at) <> '' THEN 1 ELSE 0 END) AS created_at_bad,
SUM(CASE WHEN order_id LIKE '0%' THEN 1 ELSE 0 END) AS leading_zero_rows
FROM cleaned_tmp;
要注意 TRY_CAST 并非所有引擎都提供,部分方言需要换成正则匹配或先 CAST 到临时表看报错。这条要结合自己的数据库确认,不能直接照搬。
改完后怎么验证
类型调整之后不要只看一眼转换成功就结束,建议做下面几组验证动作,并对照预期信号判断:
- 重跑同一套探查脚本:预期信号是各列「转目标类型失败行数」降为 0,或等于已确认的例外清单(例如确实存在的历史脏数据被单独标记)。两次结果要能逐列对比差异。
- 核对行数和主键唯一性:清洗和类型转换不应该无故改变行数。预期信号是总行数符合预期过滤后的数量、主键去重数等于总行数,不相等就要回头查是不是转换过程中产生了重复或丢行。
- 看极值而不是均值:每列取最大最小、最长最短、最早最晚,预期信号是这些边界值落在业务合理区间。日期列出现 1970 或 9999、金额出现负数或超大值,通常说明转换时把错误值兜底成了默认值。
- 让目标端兜底一次:把结果写进目标表,预期信号是写入过程没有隐式转换告警或截断提示。数据库报错比脚本里的 coerce 更可信。
- 抽样人工对照:从转换失败清单和极值附近各抽若干行,回原表看原始值,确认是解释得清的脏数据,而不是自己转换写错了。
容易忽略的边界
有几类问题不会报错,但会静默改变数据含义,需要单独留意。第一类是精度和范围:浮点存金额会出现小数抖动,用 int32 存超出范围的 ID 会被截断,这类问题在转换时不抛异常,只能靠范围检查发现。第二类是零和空:前导零在整数化后丢掉,NULL、''、'NULL' 三种写法语义不同,统一填充成 0 或空串会污染后续统计。第三类是时区和格式:同一列混着带时区和不带时区的时间戳,转完后排序会错乱,建议统一到一个明确的时区再落地。
还有几项限制要提前说清。CSV 读取端默认只抽样若干行做类型推断,同一个文件在不同读取参数下可能得到不同 dtype,所以建议显式指定 dtype 或全按字符串读入;pandas 的日期解析参数在不同版本间行为有差异,老版本可能不支持 format="mixed" 这类写法,使用前要结合本地版本确认。若清洗后的数据要回流给下游,改类型属于破坏性变更,回退建议是:改动前保留原表和清洗前快照,把类型变更写成显式 CAST 落成新字段而不是原地覆盖,同时在字段清单里记录变更点,出问题时能快速退回到旧字段对比。不建议在没有对照基线的情况下直接原地改类型。