Skip to content

第 13 章 常见数据场景实践 ​

前面已经分别练过字段规则、嵌套模型、校验器、序列化和 Settings。写真实代码时,往往得把它们放在一起用。本章围绕几个常见场景展开:

  1. 分页参数
  2. 文章创建数据
  3. 文章详情数据
  4. 错误整理

例如分页输入可能是:

python
query_data = {
    "page": "1",
    "page_size": "20",
    "keyword": "Pydantic",
}

文章创建输入可能是:

python
article_data = {
    "title": "Pydantic 常见数据场景",
    "content": "这是一篇介绍 Pydantic 常见数据场景的文章内容",
    "tags": ["Python", "Pydantic"],
    "author": {
        "username": "xiaoming",
        "email": "xiaoming@example.com",
    },
}

错误输入可能是:

python
bad_article_data = {
    "title": "广",
    "content": "太短",
    "tags": ["Python", "Pydantic", "实战", "校验", "错误", "超出"],
    "author": {
        "username": "xiao ming",
        "email": "not-email",
    },
}

我们会把前面学过的能力组合起来,处理分页、文章创建与详情数据,并整理校验错误。

本章学习目标 ​

把前面的知识放进同一个业务场景:分页参数单独建模,文章创建和详情也各用自己的模型。你会在其中组合 Field、嵌套模型、校验器和序列化器,再用 model_dump()、model_dump_json() 导出结果。出现校验错误时,还要能用 ValidationError.errors() 整理出更好读的错误列表。

场景与数据流 ​

继续用“文章应用”做例子。程序要接收三类数据,先看分页参数:

text
page
page_size
keyword

创建文章时,提交的数据大致是:

text
title
content
tags
summary
author

程序补充处理后字段后,才组成完整详情:

text
创建数据本身
+ id
+ views
+ is_published
+ created_at

输入有问题时,我们也会把异常整理成更易读的结果。整章流程如下:

Pydantic 常见数据场景全景架构图

每个示例都可以复制到 .code/main.py 运行;进入下一步时,用新的代码覆盖它。

分页查询参数 ​

查询模型与分页上限 ​

查询参数模型常用来处理:

  • 分页
  • 搜索
  • 筛选
  • 排序

例如:

python
class PageQuery(BaseModel):
    page: int
    page_size: int
    keyword: str | None

分页上限配置

上一章用 BaseSettings 读取应用配置。这里的 PageLimits 是普通模型,不读取环境变量,只用来演示分页上限怎样参与规则判断。

python
class PageLimits(BaseModel):
    max_page_size: int = 100

它会参与“分页大小不能超过上限”的判断。

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

py
from pydantic import BaseModel, Field, field_validator


class PageLimits(BaseModel):
    max_page_size: int = 100


class PageQuery(BaseModel):
    page: int = Field(default=1, ge=1)
    page_size: int = Field(default=10, ge=1)
    keyword: str | None = Field(default=None)

    @field_validator("keyword")
    @classmethod
    def clean_keyword(cls, value: str | None) -> str | None:
        if value is None:
            return value
        value = value.strip()
        return value or None


query_data = {
    "page": "1",
    "page_size": "20",
    "keyword": "  Pydantic  ",
}

page_limits = PageLimits(max_page_size=100)
query = PageQuery.model_validate(query_data)

if query.page_size > page_limits.max_page_size:
    raise ValueError("分页大小超过应用配置限制")

print(query.page, type(query.page))
print(query.page_size, type(query.page_size))
print(query.keyword)
print(query.model_dump(exclude_none=True))

运行:

bash
uv run python main.py
python
from pydantic import BaseModel, Field, field_validator


class PageLimits(BaseModel):
    max_page_size: int = 100


class PageQuery(BaseModel):
    page: int = Field(default=1, ge=1)
    page_size: int = Field(default=10, ge=1)
    keyword: str | None = Field(default=None)

    @field_validator("keyword")
    @classmethod
    def clean_keyword(cls, value: str | None) -> str | None:
        if value is None:
            return value
        value = value.strip()
        return value or None


query_data = {
    "page": "1",
    "page_size": "20",
    "keyword": "  Pydantic  ",
}

page_limits = PageLimits(max_page_size=100)
query = PageQuery.model_validate(query_data)

if query.page_size > page_limits.max_page_size:
    raise ValueError("分页大小超过应用配置限制")

print(query.page, type(query.page))
print(query.page_size, type(query.page_size))
print(query.keyword)
print(query.model_dump(exclude_none=True))

运行结果与规则 ​

运行后,字符串数字已转成整数,搜索词也去掉了前后空格:

text
1 <class 'int'>
20 <class 'int'>
Pydantic
{'page': 1, 'page_size': 20, 'keyword': 'Pydantic'}

页码与分页大小的类型和下限

python
page: int = Field(default=1, ge=1)
page_size: int = Field(default=10, ge=1)

字符串数字会转成整数,页码和分页大小至少为 1;最大分页大小再由 PageLimits 提供。

可选搜索词

python
keyword: str | None = Field(default=None)

搜索词清理

python
value = value.strip()
return value or None

这表示:

  • 去掉前后空格
  • 如果最后变成空字符串,就转成 None

分页大小上限

这里不再演示 BaseSettings,只说明配置对象可以参与规则判断。超过上限时,这段检查会抛 ValueError;本章的 format_validation_errors() 只接收 Pydantic 的 ValidationError。最后一章会把两类错误放进同一条处理流程。

查询参数导出

搜索词清理后若变成 None,导出时就可以把它省略。

分页参数处理流程

分页查询参数处理与清洗流水线

文章创建数据 ​

创建模型与输入 ​

创建模型描述用户提交的内容。创建文章时,通常包括:

  • 标题
  • 内容
  • 标签
  • 摘要
  • 作者

而这些字段一般由程序补充:

  • id
  • views
  • created_at

接下来定义作者模型和文章创建模型。

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

py
from pydantic import BaseModel, Field, field_validator


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

    @field_validator("username", mode="before")
    @classmethod
    def validate_username(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        value = value.strip()
        if " " in value:
            raise ValueError("用户名不能包含空格")
        return value

    @field_validator("email")
    @classmethod
    def validate_email(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


class ArticleCreate(BaseModel):
    title: str = Field(min_length=2, max_length=50)
    content: str = Field(min_length=10)
    tags: list[str] = Field(default_factory=list, max_length=5)
    summary: str | None = Field(default=None, max_length=100)
    author: UserInfo

    @field_validator("title")
    @classmethod
    def validate_title(cls, value: str) -> str:
        blocked_words = ["广告", "推广"]
        for word in blocked_words:
            if word in value:
                raise ValueError("文章标题不能包含敏感词")
        return value


article_data = {
    "title": "Pydantic 常见数据场景",
    "content": "这是一篇介绍 Pydantic 常见数据场景的文章内容",
    "tags": ["Python", "Pydantic"],
    "summary": "把常见数据场景串起来",
    "author": {
        "username": "xiaoming",
        "email": "xiaoming@example.com",
    },
}

article_create = ArticleCreate.model_validate(article_data)

print(article_create.title)
print(article_create.author.username)
print(article_create.model_dump())

运行:

bash
uv run python main.py

程序会打印文章标题、作者用户名,以及包含嵌套 author 的完整字典。这里先列出前两行:

text
Pydantic 常见数据场景
xiaoming

作者与字段规则 ​

作者模型 UserInfo

单独定义作者结构,让文章和作者各自的字段规则更清楚。

用户名校验

用户名会去掉前后空格,并拒绝中间带空格的输入。

邮箱基本结构

邮箱继续沿用前面章节的轻量规则:要有用户名和域名,中间只能有一个 @。

python
name, separator, domain = value.partition("@")
if not name or not separator or not domain or "@" in domain:
    raise ValueError("邮箱必须包含用户名和域名")

创建模型 ArticleCreate

它只描述创建时需要提交的字段。

嵌套作者信息

嵌套的作者数据也会按 UserInfo 的规则校验。

标题敏感词规则

标题的敏感词检查由 field_validator 处理。

创建数据结构

文章模型演进:ArticleCreate 到 ArticleDetail 的继承与富化

文章详情与导出 ​

详情模型与导出 ​

详情模型表示程序处理后的完整数据,通常在创建字段之外再补上:

  • id
  • views
  • is_published
  • created_at

也就是说,创建模型描述用户交来的内容,详情模型描述程序处理后保存或返回的数据。

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

py
from datetime import datetime

from pydantic import BaseModel, Field, field_serializer, field_validator, model_validator


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

    @field_validator("username", mode="before")
    @classmethod
    def validate_username(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        value = value.strip()
        if " " in value:
            raise ValueError("用户名不能包含空格")
        return value

    @field_validator("email")
    @classmethod
    def validate_email(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


class ArticleCreate(BaseModel):
    title: str = Field(min_length=2, max_length=50)
    content: str = Field(min_length=10)
    tags: list[str] = Field(default_factory=list, max_length=5)
    summary: str | None = Field(default=None, max_length=100)
    author: UserInfo

    @field_validator("title")
    @classmethod
    def validate_title(cls, value: str) -> str:
        blocked_words = ["广告", "推广"]
        for word in blocked_words:
            if word in value:
                raise ValueError("文章标题不能包含敏感词")
        return value


class ArticleDetail(ArticleCreate):
    id: int
    views: int = Field(default=0, ge=0)
    is_published: bool = Field(default=False)
    created_at: datetime

    @model_validator(mode="after")
    def validate_publish_rules(self):
        if self.is_published and not self.summary:
            raise ValueError("文章发布时必须填写摘要")
        return self

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


article_data = {
    "title": "Pydantic 常见数据场景",
    "content": "这是一篇介绍 Pydantic 常见数据场景的文章内容",
    "tags": ["Python", "Pydantic"],
    "summary": "把常见数据场景串起来",
    "author": {
        "username": "xiaoming",
        "email": "xiaoming@example.com",
    },
}

article_create = ArticleCreate.model_validate(article_data)

detail_data = {
    **article_create.model_dump(),
    "id": 1,
    "views": 0,
    "is_published": True,
    "created_at": datetime.now(),
}

article_detail = ArticleDetail.model_validate(detail_data)

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

运行:

bash
uv run python main.py
python
from datetime import datetime

from pydantic import BaseModel, Field, field_serializer, field_validator, model_validator


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

    @field_validator("username", mode="before")
    @classmethod
    def validate_username(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        value = value.strip()
        if " " in value:
            raise ValueError("用户名不能包含空格")
        return value

    @field_validator("email")
    @classmethod
    def validate_email(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


class ArticleCreate(BaseModel):
    title: str = Field(min_length=2, max_length=50)
    content: str = Field(min_length=10)
    tags: list[str] = Field(default_factory=list, max_length=5)
    summary: str | None = Field(default=None, max_length=100)
    author: UserInfo

    @field_validator("title")
    @classmethod
    def validate_title(cls, value: str) -> str:
        blocked_words = ["广告", "推广"]
        for word in blocked_words:
            if word in value:
                raise ValueError("文章标题不能包含敏感词")
        return value


class ArticleDetail(ArticleCreate):
    id: int
    views: int = Field(default=0, ge=0)
    is_published: bool = Field(default=False)
    created_at: datetime

    @model_validator(mode="after")
    def validate_publish_rules(self):
        if self.is_published and not self.summary:
            raise ValueError("文章发布时必须填写摘要")
        return self

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


article_data = {
    "title": "Pydantic 常见数据场景",
    "content": "这是一篇介绍 Pydantic 常见数据场景的文章内容",
    "tags": ["Python", "Pydantic"],
    "summary": "把常见数据场景串起来",
    "author": {
        "username": "xiaoming",
        "email": "xiaoming@example.com",
    },
}

article_create = ArticleCreate.model_validate(article_data)

detail_data = {
    **article_create.model_dump(),
    "id": 1,
    "views": 0,
    "is_published": True,
    "created_at": datetime.now(),
}

article_detail = ArticleDetail.model_validate(detail_data)

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

字典和 JSON 中的 created_at 都会按 %Y-%m-%d %H:%M 格式输出;具体时间随运行时刻变化。模型内部的 article_detail.created_at 仍是 datetime 对象。

详情字段与发布规则 ​

继承创建字段

ArticleDetail 继承 ArticleCreate,复用这些创建字段:

  • title
  • content
  • tags
  • summary
  • author

补充详情字段

详情模型再增加:

  • id
  • views
  • is_published
  • created_at

这些通常不由用户在创建时直接提供。

发布规则

它同时检查 is_published 和 summary,属于模型级联合规则。

创建时间序列化

created_at 在模型内部是 datetime,导出时才格式化成字符串。

详情数据来源

从创建数据到详情数据的变化,也能看出两类模型的职责不同。

创建模型到详情模型流程

文章模型演进:ArticleCreate 到 ArticleDetail 的继承与富化

校验错误整理 ​

字段错误示例 ​

直接打印异常对象:

python
print(e)

可以看到错误概况。若要按字段展示,就读取结构化错误列表:

python
e.errors()

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

py
from pydantic import BaseModel, Field, ValidationError, field_validator


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

    @field_validator("username", mode="before")
    @classmethod
    def validate_username(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        value = value.strip()
        if " " in value:
            raise ValueError("用户名不能包含空格")
        return value

    @field_validator("email")
    @classmethod
    def validate_email(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


class ArticleCreate(BaseModel):
    title: str = Field(min_length=2, max_length=50)
    content: str = Field(min_length=10)
    tags: list[str] = Field(default_factory=list, max_length=5)
    summary: str | None = Field(default=None, max_length=100)
    author: UserInfo

    @field_validator("title")
    @classmethod
    def validate_title(cls, value: str) -> str:
        blocked_words = ["广告", "推广"]
        for word in blocked_words:
            if word in value:
                raise ValueError("文章标题不能包含敏感词")
        return value


def format_validation_errors(error: ValidationError) -> list[dict]:
    result = []

    for item in error.errors():
        result.append(
            {
                "field": ".".join(str(loc) for loc in item["loc"]) or "模型",
                "message": item["msg"],
                "input": item.get("input"),
            }
        )

    return result


bad_article_data = {
    "title": "广",
    "content": "太短",
    "tags": ["Python", "Pydantic", "实战", "校验", "错误", "超出"],
    "author": {
        "username": "xiao ming",
        "email": "not-email",
    },
}

try:
    article_create = ArticleCreate.model_validate(bad_article_data)
    print(article_create)
except ValidationError as error:
    errors = format_validation_errors(error)
    for item in errors:
        print(item)

运行:

bash
uv run python main.py
python
from pydantic import BaseModel, Field, ValidationError, field_validator


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

    @field_validator("username", mode="before")
    @classmethod
    def validate_username(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        value = value.strip()
        if " " in value:
            raise ValueError("用户名不能包含空格")
        return value

    @field_validator("email")
    @classmethod
    def validate_email(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


class ArticleCreate(BaseModel):
    title: str = Field(min_length=2, max_length=50)
    content: str = Field(min_length=10)
    tags: list[str] = Field(default_factory=list, max_length=5)
    summary: str | None = Field(default=None, max_length=100)
    author: UserInfo

    @field_validator("title")
    @classmethod
    def validate_title(cls, value: str) -> str:
        blocked_words = ["广告", "推广"]
        for word in blocked_words:
            if word in value:
                raise ValueError("文章标题不能包含敏感词")
        return value


def format_validation_errors(error: ValidationError) -> list[dict]:
    result = []

    for item in error.errors():
        result.append(
            {
                "field": ".".join(str(loc) for loc in item["loc"]) or "模型",
                "message": item["msg"],
                "input": item.get("input"),
            }
        )

    return result


bad_article_data = {
    "title": "广",
    "content": "太短",
    "tags": ["Python", "Pydantic", "实战", "校验", "错误", "超出"],
    "author": {
        "username": "xiao ming",
        "email": "not-email",
    },
}

try:
    article_create = ArticleCreate.model_validate(bad_article_data)
    print(article_create)
except ValidationError as error:
    errors = format_validation_errors(error)
    for item in errors:
        print(item)

例如,作者邮箱字段的错误会整理成:

text
{'field': 'author.email', 'message': 'Value error, 邮箱必须包含用户名和域名', 'input': 'not-email'}

错误位置与输出边界 ​

结构化错误列表

errors() 返回的结构比直接打印异常更方便继续处理。

字段路径

例如:

python
("author", "email")

可以变成:

text
author.email

如果错误来自 model_validator,loc 可能是空元组。函数会把这种位置标成“模型”,完整案例里能看到这个分支。

错误说明

python
item["msg"]

原始输入

本地排错时可以用原始输入定位问题。如果要把错误显示在界面或返回给调用方,只保留需要的字段,避免直接暴露输入内容:

python
public_errors = [
    {"field": item["field"], "message": item["message"]}
    for item in errors
]

统一格式

可以按场景挑选要输出的字段,但尽量保持错误结构稳定。

错误整理流程

校验异常结构化解析与生产格式化流水线

文章数据处理综合案例 ​

最后用一个例子把本章几个场景串起来:

数据场景组合与整体处理流程

Pydantic 常见数据场景全景架构图

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

py
from datetime import datetime

from pydantic import (
    BaseModel,
    Field,
    ValidationError,
    field_serializer,
    field_validator,
    model_validator,
)


class PageLimits(BaseModel):
    max_page_size: int = 100


class PageQuery(BaseModel):
    page: int = Field(default=1, ge=1)
    page_size: int = Field(default=10, ge=1)
    keyword: str | None = Field(default=None)

    @field_validator("keyword")
    @classmethod
    def clean_keyword(cls, value: str | None) -> str | None:
        if value is None:
            return value
        value = value.strip()
        return value or None


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

    @field_validator("username", mode="before")
    @classmethod
    def validate_username(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        value = value.strip()
        if " " in value:
            raise ValueError("用户名不能包含空格")
        return value

    @field_validator("email")
    @classmethod
    def validate_email(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


class ArticleCreate(BaseModel):
    title: str = Field(min_length=2, max_length=50)
    content: str = Field(min_length=10)
    tags: list[str] = Field(default_factory=list, max_length=5)
    summary: str | None = Field(default=None, max_length=100)
    author: UserInfo

    @field_validator("title")
    @classmethod
    def validate_title(cls, value: str) -> str:
        blocked_words = ["广告", "推广"]
        for word in blocked_words:
            if word in value:
                raise ValueError("文章标题不能包含敏感词")
        return value


class ArticleDetail(ArticleCreate):
    id: int
    views: int = Field(default=0, ge=0)
    is_published: bool = Field(default=False)
    created_at: datetime

    @model_validator(mode="after")
    def validate_publish_rules(self):
        if self.is_published and not self.summary:
            raise ValueError("文章发布时必须填写摘要")
        return self

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


def format_validation_errors(error: ValidationError) -> list[dict]:
    result = []
    for item in error.errors():
        result.append(
            {
                "field": ".".join(str(loc) for loc in item["loc"]) or "模型",
                "message": item["msg"],
                "input": item.get("input"),
            }
        )
    return result


page_limits = PageLimits(max_page_size=100)

query_data = {
    "page": "1",
    "page_size": "20",
    "keyword": "  Pydantic  ",
}

article_data = {
    "title": "Pydantic 常见数据场景",
    "content": "这是一篇介绍 Pydantic 常见数据场景的文章内容",
    "tags": ["Python", "Pydantic"],
    "summary": "把常见数据场景串起来",
    "author": {
        "username": "xiaoming",
        "email": "xiaoming@example.com",
    },
}

bad_article_data = {
    "title": "广",
    "content": "太短",
    "tags": ["Python", "Pydantic", "实战", "校验", "错误", "超出"],
    "author": {
        "username": "xiao ming",
        "email": "not-email",
    },
}

query = PageQuery.model_validate(query_data)
if query.page_size > page_limits.max_page_size:
    raise ValueError("分页大小超过应用配置限制")
print(query.model_dump(exclude_none=True))

article_create = ArticleCreate.model_validate(article_data)
print(article_create.model_dump())

detail_data = {
    **article_create.model_dump(),
    "id": 1,
    "views": 0,
    "is_published": True,
    "created_at": datetime.now(),
}

article_detail = ArticleDetail.model_validate(detail_data)
print(article_detail.model_dump())
print(article_detail.model_dump_json(indent=2))

try:
    ArticleCreate.model_validate(bad_article_data)
except ValidationError as error:
    print(format_validation_errors(error))

bad_detail_data = {**detail_data, "summary": None}
try:
    ArticleDetail.model_validate(bad_detail_data)
except ValidationError as error:
    for item in format_validation_errors(error):
        print({"field": item["field"], "message": item["message"]})

运行:

bash
uv run python main.py
python
from datetime import datetime

from pydantic import (
    BaseModel,
    Field,
    ValidationError,
    field_serializer,
    field_validator,
    model_validator,
)


class PageLimits(BaseModel):
    max_page_size: int = 100


class PageQuery(BaseModel):
    page: int = Field(default=1, ge=1)
    page_size: int = Field(default=10, ge=1)
    keyword: str | None = Field(default=None)

    @field_validator("keyword")
    @classmethod
    def clean_keyword(cls, value: str | None) -> str | None:
        if value is None:
            return value
        value = value.strip()
        return value or None


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

    @field_validator("username", mode="before")
    @classmethod
    def validate_username(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        value = value.strip()
        if " " in value:
            raise ValueError("用户名不能包含空格")
        return value

    @field_validator("email")
    @classmethod
    def validate_email(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


class ArticleCreate(BaseModel):
    title: str = Field(min_length=2, max_length=50)
    content: str = Field(min_length=10)
    tags: list[str] = Field(default_factory=list, max_length=5)
    summary: str | None = Field(default=None, max_length=100)
    author: UserInfo

    @field_validator("title")
    @classmethod
    def validate_title(cls, value: str) -> str:
        blocked_words = ["广告", "推广"]
        for word in blocked_words:
            if word in value:
                raise ValueError("文章标题不能包含敏感词")
        return value


class ArticleDetail(ArticleCreate):
    id: int
    views: int = Field(default=0, ge=0)
    is_published: bool = Field(default=False)
    created_at: datetime

    @model_validator(mode="after")
    def validate_publish_rules(self):
        if self.is_published and not self.summary:
            raise ValueError("文章发布时必须填写摘要")
        return self

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


def format_validation_errors(error: ValidationError) -> list[dict]:
    result = []
    for item in error.errors():
        result.append(
            {
                "field": ".".join(str(loc) for loc in item["loc"]) or "模型",
                "message": item["msg"],
                "input": item.get("input"),
            }
        )
    return result


page_limits = PageLimits(max_page_size=100)

query_data = {
    "page": "1",
    "page_size": "20",
    "keyword": "  Pydantic  ",
}

article_data = {
    "title": "Pydantic 常见数据场景",
    "content": "这是一篇介绍 Pydantic 常见数据场景的文章内容",
    "tags": ["Python", "Pydantic"],
    "summary": "把常见数据场景串起来",
    "author": {
        "username": "xiaoming",
        "email": "xiaoming@example.com",
    },
}

bad_article_data = {
    "title": "广",
    "content": "太短",
    "tags": ["Python", "Pydantic", "实战", "校验", "错误", "超出"],
    "author": {
        "username": "xiao ming",
        "email": "not-email",
    },
}

query = PageQuery.model_validate(query_data)
if query.page_size > page_limits.max_page_size:
    raise ValueError("分页大小超过应用配置限制")
print(query.model_dump(exclude_none=True))

article_create = ArticleCreate.model_validate(article_data)
print(article_create.model_dump())

detail_data = {
    **article_create.model_dump(),
    "id": 1,
    "views": 0,
    "is_published": True,
    "created_at": datetime.now(),
}

article_detail = ArticleDetail.model_validate(detail_data)
print(article_detail.model_dump())
print(article_detail.model_dump_json(indent=2))

try:
    ArticleCreate.model_validate(bad_article_data)
except ValidationError as error:
    print(format_validation_errors(error))

bad_detail_data = {**detail_data, "summary": None}
try:
    ArticleDetail.model_validate(bad_detail_data)
except ValidationError as error:
    for item in format_validation_errors(error):
        print({"field": item["field"], "message": item["message"]})

运行后,分页结果中的 page 和 page_size 是整数,详情导出的 created_at 是字符串。错误输出则分别展示字段路径和模型级位置:

text
{'field': 'author.email', 'message': 'Value error, 邮箱必须包含用户名和域名', 'input': 'not-email'}
{'field': '模型', 'message': 'Value error, 文章发布时必须填写摘要'}

第一行来自创建数据的错误列表,第二行来自 bad_detail_data。模型级错误只输出字段名和说明,没有把整份文章输入带到界面上。

完整案例执行流程

Pydantic 常见数据场景全景架构图

本章小结 ​

分页查询、文章创建、文章详情各有自己的数据边界,拆成不同模型更容易处理。固定的子结构交给嵌套模型,程序处理后新增的字段放进详情模型。出错时,ValidationError.errors() 提供结构化信息,可以整理成后续展示或处理的错误列表。