在基于 Pydantic 做数据建模时,我们经常会定义一些基类来存放通用字段,例如创建时间、更新时间、操作人等。当某个子类模型用于特定场景(如对外 API 响应)时,往往希望把父类中的部分字段排除掉。由于 Pydantic 分为 v1 与 v2 两个大版本,它们的字段排除 API 并不一致,如果处理不当,代码在升级后就会失效或产生不符合预期的输出。本文围绕模型继承下的字段排除需求,给出一套同时兼容两个版本的实用方案。

为什么需要排除父类字段
在实际项目中,基类模型通常承担复用职责。比如下面这个简化的基类,包含了所有表都要有的审计信息:
from pydantic import BaseModel
class BaseAudit(BaseModel):
id: int
created_at: str
updated_at: str
operator: str
当我们需要一个面向前端的用户展示模型时,可能并不想暴露 operator 这种内部字段。如果直接在子类里重复声明字段而忽略它,不仅冗余,还会让基类失去约束意义。更合理的做法是让子类继承基类,但在序列化或校验时动态排除指定字段。
这种排除应该是非侵入式的,即不修改基类定义,也不在子类里写死丢弃逻辑,否则一旦基类字段调整,所有子类都要跟着改。兼容 v1 和 v2 的核心难点就在于,两个版本提供的排除入口完全不同。
Pydantic v1 的排除方式
在 Pydantic v1 中,模型实例的 dict() 方法支持 exclude 参数,可以传入字段名集合或嵌套字典来实现排除。对于扁平的父类字段,直接传字符串集合即可。
from pydantic import BaseModel
class BaseAudit(BaseModel):
id: int
created_at: str
updated_at: str
operator: str
class UserOut(BaseAudit):
name: str
u = UserOut(id=1, created_at='2023', updated_at='2023', operator='admin', name='tom')
# v1 排除 operator 字段
data = u.dict(exclude={'operator'})
print(data)
上述代码运行后会得到不包含 operator 的字典。需要注意的是,v1 的 exclude 也能接收类似 {'field': True} 的字典形式,但在简单继承场景里用集合更直观。如果父类字段本身是嵌套模型,则需要用嵌套字典指明路径。
v1 方案的优点是语法简单,缺点是所有调用方都必须记得传 exclude,且它仅影响 dict() 或 json() 输出,不影响模型本身的字段定义。当项目升级到 v2,dict() 被标记为废弃,继续使用会收到警告。
Pydantic v2 的排除方式
Pydantic v2 将序列化方法统一为 model_dump() 与 model_dump_json(),同样提供 exclude 参数,但底层实现改为基于字段元数据与配置,性能更好且语义更清晰。
from pydantic import BaseModel
class BaseAudit(BaseModel):
id: int
created_at: str
updated_at: str
operator: str
class UserOut(BaseAudit):
name: str
u = UserOut(id=1, created_at='2023', updated_at='2023', operator='admin', name='tom')
# v2 排除 operator 字段
data = u.model_dump(exclude={'operator'})
print(data)
在 v2 中,exclude 的用法与 v1 基本对齐,但官方推荐配合 model_config 或 Field 的 exclude 属性做静态排除。例如若某个字段永远不该出现在输出里,可在父类用 Field(exclude=True) 声明,这样所有子类自动继承该行为。
不过静态排除会污染基类,使其在特定上下文之外也丢失字段,因此动态传参更灵活。v2 的另一处改进是 exclude 支持更丰富的表达式,比如用元组排除某些嵌套位置,但本文聚焦父类扁平字段,不再展开。
兼容 v1/v2 的最佳实践
为了让同一份业务代码在 v1 和 v2 下都能排除父类字段,我们可以封装一个工具函数,根据已安装的 Pydantic 版本切换调用方式。这样既隐藏了版本差异,也方便后续统一下线 v1 逻辑。
import pydantic
def dump_exclude(model, exclude_fields):
if pydantic.VERSION.startswith('1.'):
return model.dict(exclude=set(exclude_fields))
else:
return model.model_dump(exclude=set(exclude_fields))
class BaseAudit(pydantic.BaseModel):
id: int
created_at: str
updated_at: str
operator: str
class UserOut(BaseAudit):
name: str
u = UserOut(id=1, created_at='2023', updated_at='2023', operator='admin', name='tom')
result = dump_exclude(u, ['operator'])
print(result)
该函数先通过 pydantic.VERSION 判断大版本,v1 走 dict,v2 走 model_dump,对外接口保持一致。业务层只需调用 dump_exclude 并声明要排除的字段名,不必关心底层是哪一个 Pydantic 版本。
如果项目处于过渡期,建议把这类兼容函数放在公共模块,并补充单元测试覆盖两个版本的预期输出。当全线升级到 v2 后,直接删除版本分支即可,子类代码无需改动。相比在子类里重写 model_dump 或用继承覆盖字段,这种中心化处理方式维护成本最低,也避免了人为遗漏排除项。
常见误区与注意点
一个常见误区是认为在子类里用 operator: None 覆盖父类字段就能排除它,实际上这只会改变类型和默认值,字段依旧存在并被序列化。另一个误区是依赖 delattr 在运行时删实例属性,这不仅对模型校验无效,还可能破坏 Pydantic 的内部状态。
# 错误示范:覆盖并不能排除
class UserOutWrong(BaseAudit):
operator: None = None # 字段还在,只是变成 None
此外,如果父类来自第三方库且无法修改,动态 exclude 几乎是唯一安全的选择。若使用静态 Field(exclude=True),请确认该父类不会在其他需要完整字段的场景被复用,否则会造成数据丢失。综合来看,封装兼容函数配合动态排除,是兼顾稳健与可维护性的最佳实践。