Skip to content

第 10 章 字段序列化与输出格式 ​

上一章讲的是导出结果里要包含哪些字段:

  • 用 model_dump() 导出字典
  • 用 model_dump_json() 导出 JSON 字符串
  • 用 include、exclude、exclude_none 控制哪些字段导出

有时字段需要保留,但导出时要换一种表现形式:

  • published_at 在模型内部是 datetime,导出时想变成 "2026-06-05 10:30"
  • email 在模型内部保存完整邮箱,导出时想变成脱敏邮箱
  • tags 在模型内部是 list[str],导出时想变成一段文本
  • views 在模型内部是整数,导出时想变成 "100 次阅读"

这不是输入校验,也不是把字段筛掉,而是用 field_serializer 控制导出值,同时保留模型内部原值。

本章学习目标 ​

有时模型里的值没问题,导出给别人看时却需要换个样子。这章用 @field_serializer("字段名") 控制单字段输出:把 datetime 格式化成指定字符串,把邮箱做简单脱敏。做完后,你应能说清它与 field_validator 分别在什么时候起作用。

字段序列化基础 ​

继续用文章详情做例子。为了把注意力放在序列化上,代码只保留本章用到的字段,并加上 published_at;前章的完整业务规则就不重复了。

现在我们关注这些字段:

text
published_at -> 发布时间,内部类型是 datetime
author.email -> 作者邮箱,内部保存完整值
tags         -> 标签列表,内部类型是 list[str]
views        -> 阅读量,内部类型是 int

导出时则希望看到这些值:

text
published_at -> "2026-06-05 10:30"
author.email -> "x***g@example.com"
tags         -> "Python, Pydantic, 序列化"
views        -> "100 次阅读"

内部保留便于 Python 处理的数据类型,到了导出这一步,再换成适合展示的格式。

先看它在输入和导出流程中的位置:

Pydantic 校验与序列化双向生命周期架构

每个示例都是完整代码,可以复制到 .code/main.py 运行;下一步再用新示例覆盖它。

发布时间格式化 ​

目标是模型内部保留 datetime,页面上显示为 "2026-06-05 10:30"。先比较默认导出和自定义格式,把下面的代码保存为 .code/main.py:

py
from datetime import datetime

from pydantic import BaseModel, Field, field_serializer


class ArticleDefault(BaseModel):
    title: str = Field(min_length=2, max_length=50)
    published_at: datetime


class ArticleDetail(BaseModel):
    title: str = Field(min_length=2, max_length=50)
    published_at: datetime

    @field_serializer("published_at")
    def serialize_published_at(self, value: datetime) -> str:
        return value.strftime("%Y-%m-%d %H:%M")


data = {
    "title": "Pydantic 字段序列化",
    "published_at": "2026-06-05T10:30:00",
}

default_article = ArticleDefault.model_validate(data)
article = ArticleDetail.model_validate(data)
default_dict = default_article.model_dump()
article_dict = article.model_dump()

print(default_dict["published_at"], type(default_dict["published_at"]))
print(default_article.model_dump_json())
print(article.published_at, type(article.published_at))
print(article_dict["published_at"], type(article_dict["published_at"]))

运行:

bash
uv run python main.py

输出如下:

text
2026-06-05 10:30:00 <class 'datetime.datetime'>
{"title":"Pydantic 字段序列化","published_at":"2026-06-05T10:30:00"}
2026-06-05 10:30:00 <class 'datetime.datetime'>
2026-06-05 10:30 <class 'str'>

前两行来自没有序列化器的 ArticleDefault:model_dump() 保留 datetime 对象,model_dump_json() 则按默认规则输出 JSON 时间字符串。后两行来自 ArticleDetail:内部值依然是 datetime,model_dump() 的结果已经按指定格式变成字符串。想要这种展示格式,就需要 field_serializer。

model_dump() 的返回值

model_dump() 负责:

text
把模型对象导出成普通字典

本身不会按本例的展示要求改写时间格式。

model_dump_json() 的返回值

model_dump_json() 负责:

text
把模型对象导出成 JSON 字符串

它会按默认规则把 datetime 写成 JSON 时间字符串,但不会自动使用本例指定的格式。

field_serializer 的作用

field_serializer 则负责:

text
某个字段导出时,应该变成什么值

例如:

python
@field_serializer("published_at")
def serialize_published_at(self, value: datetime) -> str:
    return value.strftime("%Y-%m-%d %H:%M")

所以导出 published_at 时,结果是格式化字符串,模型里的 datetime 不变。

输入校验与输出序列化流程

Pydantic 校验与序列化双向生命周期架构

序列化器只在导出时生效,核心逻辑就是:

python
@field_serializer("published_at")
def serialize_published_at(self, value: datetime) -> str:
    return value.strftime("%Y-%m-%d %H:%M")

模型内部的 published_at 是 datetime,导出结果中的值则是字符串。

发布时间序列化流程

内部原生类型与序列化导出表现对比拓扑

邮箱脱敏 ​

邮箱例子里会同时用到校验器和序列化器,它们的分工很简单:

工具发生阶段主要作用
field_validator输入阶段清理或检查输入值
field_serializer输出阶段控制导出值

field_validator 检查输入,field_serializer 改写导出值。接着看邮箱脱敏:函数需要用户名和域名都存在,所以示例先检查这两个部分,再生成脱敏结果。这里不做完整的邮箱格式校验。模型内部仍保存完整邮箱。

将下面的完整代码保存为 .code/main.py:

py
from pydantic import BaseModel, Field, ValidationError, field_serializer, field_validator


class UserInfo(BaseModel):
    username: str = Field(min_length=2, max_length=20)
    email: str

    @field_validator("email")
    @classmethod
    def validate_email_structure(cls, value: str) -> str:
        name, separator, domain = value.partition("@")
        if not name or not separator or not domain or "@" in domain:
            raise ValueError("邮箱必须包含用户名和域名")
        return value

    @field_serializer("email")
    def serialize_email(self, value: str) -> str:
        name, domain = value.split("@", 1)
        if len(name) <= 2:
            hidden_name = name[0] + "*"
        else:
            hidden_name = name[0] + "***" + name[-1]
        return hidden_name + "@" + domain


data = {
    "username": "xiaoming",
    "email": "xiaoming@example.com",
}

user = UserInfo.model_validate(data)
user_dict = user.model_dump()

print(user.email)
print(user_dict["email"])

try:
    UserInfo.model_validate({"username": "xiaoming", "email": "@example.com"})
except ValidationError as exc:
    print(exc.errors()[0]["msg"])

运行:

bash
uv run python main.py

输出如下:

text
xiaoming@example.com
x***g@example.com
Value error, 邮箱必须包含用户名和域名

前两行对比了内部值和导出值。第三行展示无用户名的 @example.com 在输入阶段就被拒绝了;问题在校验时发现,不会拖到导出时才报错。

模型内部值

python
print(user.email)

输出的还是完整值。

导出值

python
print(user_dict["email"])

这时才会看到脱敏结果。

因此,field_validator 只检查脱敏所需的邮箱结构,field_serializer 负责生成展示值;内部保存的邮箱不会被改动。

邮箱脱敏序列化流程

内部原生类型与序列化导出表现对比拓扑

多字段序列化 ​

把几个序列化器放进同一个模型,分别处理:

  1. published_at 的时间格式
  2. tags 列表转文本
  3. views 阅读量转展示文本
  4. author.email 邮箱脱敏

将下面的完整代码保存为 .code/main.py:

py
from datetime import datetime

from pydantic import BaseModel, Field, field_serializer, field_validator


class UserInfo(BaseModel):
    username: str = Field(min_length=2, max_length=20)
    email: str

    @field_validator("email")
    @classmethod
    def validate_email_structure(cls, value: str) -> str:
        name, separator, domain = value.partition("@")
        if not name or not separator or not domain or "@" in domain:
            raise ValueError("邮箱必须包含用户名和域名")
        return value

    @field_serializer("email")
    def serialize_email(self, value: str) -> str:
        name, domain = value.split("@", 1)
        if len(name) <= 2:
            hidden_name = name[0] + "*"
        else:
            hidden_name = name[0] + "***" + name[-1]
        return hidden_name + "@" + domain


class ArticleDetail(BaseModel):
    title: str = Field(min_length=2, max_length=50)
    content: str = Field(min_length=10)
    published_at: datetime
    views: int = Field(default=0, ge=0)
    tags: list[str] = Field(default_factory=list)
    author: UserInfo

    @field_serializer("published_at")
    def serialize_published_at(self, value: datetime) -> str:
        return value.strftime("%Y-%m-%d %H:%M")

    @field_serializer("tags")
    def serialize_tags(self, value: list[str]) -> str:
        return ", ".join(value)

    @field_serializer("views")
    def serialize_views(self, value: int) -> str:
        return f"{value} 次阅读"


data = {
    "title": "Pydantic 字段序列化",
    "content": "这是一篇介绍 field_serializer 的文章内容",
    "published_at": "2026-06-05T10:30:00",
    "views": 100,
    "tags": ["Python", "Pydantic", "序列化"],
    "author": {
        "username": "xiaoming",
        "email": "xiaoming@example.com",
    },
}

article = ArticleDetail.model_validate(data)

print(article.published_at, type(article.published_at))
print(article.views, type(article.views))
print(article.tags, type(article.tags))
print(article.author.email)

print(article.model_dump())
print(article.model_dump_json(indent=2))

运行:

bash
uv run python main.py

字典和 JSON 中都能看到类似结果:

json
{
  "published_at": "2026-06-05 10:30",
  "views": "100 次阅读",
  "tags": "Python, Pydantic, 序列化",
  "author": {
    "username": "xiaoming",
    "email": "x***g@example.com"
  }
}

这里只截取了相关字段,完整结果还包括 title 和 content。模型内部的 views 仍是整数,tags 仍是列表,author.email 也仍是完整邮箱。

本例是给页面展示用的,所以阅读量和标签都转成了文本。默认的 field_serializer 会同时作用于 model_dump() 和 model_dump_json()。如果某个接口要求 views 是数字、tags 是列表,就别直接把这份展示格式当作接口数据。

代码中几个字段各自的处理方式如下:

published_at:datetime 转字符串

python
@field_serializer("published_at")
def serialize_published_at(self, value: datetime) -> str:
    return value.strftime("%Y-%m-%d %H:%M")

tags:列表转展示文本

python
@field_serializer("tags")
def serialize_tags(self, value: list[str]) -> str:
    return ", ".join(value)

views:整数转展示文本

python
@field_serializer("views")
def serialize_views(self, value: int) -> str:
    return f"{value} 次阅读"

嵌套模型序列化器

author.email 的序列化器写在 UserInfo 里,但当 ArticleDetail 导出时,它仍然会一起生效。

导出外层模型时,嵌套模型里的序列化器也会执行。

多字段序列化结果对比

内部原生类型与序列化导出表现对比拓扑

内部值与导出结果 ​

序列化器不改变模型内部值

以 published_at: datetime 为例,访问 article.published_at 拿到的依旧是 datetime;只有调用 article.model_dump(),导出字典里的值才会变成字符串。为了看清两者的区别,运行下面的代码:

将下面的完整代码保存为 .code/main.py:

py
from datetime import datetime

from pydantic import BaseModel, Field, field_serializer


class ArticleDetail(BaseModel):
    title: str
    published_at: datetime
    views: int = Field(default=0, ge=0)
    tags: list[str] = Field(default_factory=list)

    @field_serializer("published_at")
    def serialize_published_at(self, value: datetime) -> str:
        return value.strftime("%Y-%m-%d %H:%M")

    @field_serializer("views")
    def serialize_views(self, value: int) -> str:
        return f"{value} 次阅读"

    @field_serializer("tags")
    def serialize_tags(self, value: list[str]) -> str:
        return ", ".join(value)


data = {
    "title": "Pydantic 字段序列化",
    "published_at": "2026-06-05T10:30:00",
    "views": 100,
    "tags": ["Python", "Pydantic"],
}

article = ArticleDetail.model_validate(data)
article_dict = article.model_dump()

print(article.published_at, type(article.published_at))
print(article.views, type(article.views))
print(article.tags, type(article.tags))

print(article_dict["published_at"], type(article_dict["published_at"]))
print(article_dict["views"], type(article_dict["views"]))
print(article_dict["tags"], type(article_dict["tags"]))

运行:

bash
uv run python main.py

六行结果先列模型内部值,再列对应的导出值:

text
2026-06-05 10:30:00 <class 'datetime.datetime'>
100 <class 'int'>
['Python', 'Pydantic'] <class 'list'>
2026-06-05 10:30 <class 'str'>
100 次阅读 <class 'str'>
Python, Pydantic <class 'str'>

内部仍是 datetime、int、list;只有导出结果变成了 str。

文章字段序列化案例 ​

最后把本章几种格式整理到一个例子里:

将下面的完整代码保存为 .code/main.py:

py
from datetime import datetime

from pydantic import BaseModel, Field, field_serializer, field_validator


class UserInfo(BaseModel):
    username: str = Field(min_length=2, max_length=20)
    email: str

    @field_validator("email")
    @classmethod
    def validate_email_structure(cls, value: str) -> str:
        name, separator, domain = value.partition("@")
        if not name or not separator or not domain or "@" in domain:
            raise ValueError("邮箱必须包含用户名和域名")
        return value

    @field_serializer("email")
    def serialize_email(self, value: str) -> str:
        name, domain = value.split("@", 1)
        if len(name) <= 2:
            hidden_name = name[0] + "*"
        else:
            hidden_name = name[0] + "***" + name[-1]
        return hidden_name + "@" + domain


class ArticleDetail(BaseModel):
    title: str = Field(min_length=2, max_length=50)
    content: str = Field(min_length=10)
    published_at: datetime
    views: int = Field(default=0, ge=0)
    tags: list[str] = Field(default_factory=list)
    author: UserInfo

    @field_serializer("published_at")
    def serialize_published_at(self, value: datetime) -> str:
        return value.strftime("%Y-%m-%d %H:%M")

    @field_serializer("tags")
    def serialize_tags(self, value: list[str]) -> str:
        return ", ".join(value)

    @field_serializer("views")
    def serialize_views(self, value: int) -> str:
        return f"{value} 次阅读"


data = {
    "title": "Pydantic 字段序列化",
    "content": "这是一篇介绍 field_serializer 的文章内容",
    "published_at": "2026-06-05T10:30:00",
    "views": 100,
    "tags": ["Python", "Pydantic", "序列化"],
    "author": {
        "username": "xiaoming",
        "email": "xiaoming@example.com",
    },
}

article = ArticleDetail.model_validate(data)

article_dict = article.model_dump()
article_json = article.model_dump_json(indent=2)

print(article.published_at, type(article.published_at))
print(article.views, type(article.views))
print(article.tags, type(article.tags))
print(article.author.email)

print(article_dict)
print(article_dict["author"]["email"])
print(article_json)

运行:

bash
uv run python main.py

运行结果里,模型内部的阅读量还是 100,作者邮箱还是 xiaoming@example.com;导出的字典和 JSON 则使用格式化后的值:

text
100 <class 'int'>
xiaoming@example.com
x***g@example.com
text
"views": "100 次阅读",
"email": "x***g@example.com"

上面是输出节选,完整 JSON 还包含文章其他字段。

对照输出看两件事:内部值保留原类型,导出时才应用展示格式;model_dump() 和 model_dump_json() 都会执行序列化器。

本章小结 ​

想改字段导出的样子,用 field_serializer。它在导出阶段运行,模型内部保存的值不会因此改变。field_validator 负责输入时的检查和处理,field_serializer 负责输出时的格式。