Pydantic validate_call 深度指南:用类型注解为任意函数注入参数校验
validate_call 是 Pydantic 提供的一个函数装饰器,它利用函数的类型注解,在被装饰函数真正执行之前自动完成参数的解析与校验(可选地校验返回值),并以极少的样板代码把 Pydantic 的校验能力带到普通函数、方法、偏函数(partial)与 lambda 上。本文以仓库中的 API 参考文档 及其指向的完整用法文档为主体,结合 装饰器源码、内部实现 与 测试用例 进行纵深讲解,读完你将能熟练使用 validate_call 的两种调用形式、掌握所有参数形态的校验、Field() 约束、别名、自定义配置与异步场景,并理解其底层原理与性能边界。
validate_call 是什么
validate_call 定义于 pydantic/validate_call_decorator.py(__all__ = ('validate_call',),并从 pydantic/init.py 顶层导出)。它返回一个包裹原函数的包装函数,在函数调用前根据注解校验实参:
- 用法既可以是裸装饰器
@validate_call,也可以是带参形式@validate_call(...); - 带参形式支持两个关键字参数:
config(配置字典)与validate_return(是否校验返回值); - 当被装饰的调用失败时,抛出标准的
pydantic_core.ValidationError,错误信息会明确指出被拒绝的参数与输入值。
官方文档说明,validate_call 底层与模型创建和初始化共用同一套机制(参见 Validators 概念文档),也就是“用类型注解生成 core schema → 交给 pydantic-core 执行校验”的完整链路,但对使用者而言,它提供了一种极低样板的方式把校验注入到任意函数中。
从源码看,装饰器的核心入口在 validate_call() 函数(validate_call_decorator.py):先调用 _check_function_type 检查函数类型,再构造 _validate_call.ValidateCallWrapper,最后通过 update_wrapper_attributes 生成保留原函数签名与文档的包装函数。
基础用法与首个示例
最简单的用法是直接给函数加上 @validate_call:
from pydantic import ValidationError, validate_call
@validate_call
def repeat(s: str, count: int, *, separator: bytes = b'') -> bytes:
b = s.encode()
return separator.join(b for _ in range(count))
a = repeat('hello', 3)
print(a)
#> b'hellohellohello'
b = repeat('x', '4', separator=b' ')
print(b)
#> b'x x x x'
try:
c = repeat('hello', 'wrong')
except ValidationError as exc:
print(exc)
"""
1 validation error for repeat
1
Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='wrong', input_type=str]
"""
注意第二个调用 repeat('x', '4', ...):'4' 是字符串,但被自动转换为整数 4 后才传入函数体。这正是 validate_call 的默认行为——与 Pydantic 模型一致,类型默认会被“宽容模式”的强制转换(coercion)。而第三个调用传入 'wrong' 时,校验失败抛出 ValidationError,错误信息清楚标明是第 2 个位置参数(1)无法解析为整数。
参数类型推断与类型转换
参数类型完全由函数注解推断;未注解的参数会被推断为 Any,这意味着它不经过任何校验。所有在 types 概念文档 中列出的类型都可以被校验,包括 Pydantic 模型与自定义类型。
类型转换(coercion)在参数传入真实函数前由装饰器完成,下面的例子展示了字符串如何被转换为 date 对象:
from datetime import date
from pydantic import validate_call
@validate_call
def greater_than(d1: date, d2: date, *, include_equal=False) -> date: # (1)!
if include_equal:
return d1 >= d2
else:
return d1 > d2
d1 = '2000-01-01' # (2)!
d2 = date(2001, 1, 1)
greater_than(d1, d2, include_equal=True)
这种自动转换非常有用,但也可能造成困惑(详见 模型数据转换)。如果你不希望发生转换,可以通过自定义配置开启 Strict 严格模式。
注意:默认不校验返回值。默认情况下函数返回值不做校验;如需校验,将装饰器的
validate_return参数设为True即可。
支持的函数签名形态
validate_call 设计上支持所有可能的参数配置及其任意组合:
- 带默认值或不带默认值的位置参数 / 关键字参数;
- 仅关键字参数:
*之后的参数; - 仅位置参数:
/之前的参数; - 可变位置参数:
*args; - 可变关键字参数:
**kwargs。
from pydantic import validate_call
@validate_call
def pos_or_kw(a: int, b: int = 2) -> str:
return f'a={a} b={b}'
print(pos_or_kw(1, b=3))
#> a=1 b=3
@validate_call
def kw_only(*, a: int, b: int = 2) -> str:
return f'a={a} b={b}'
print(kw_only(a=1))
#> a=1 b=2
print(kw_only(a=1, b=3))
#> a=1 b=3
@validate_call
def pos_only(a: int, b: int = 2, /) -> str:
return f'a={a} b={b}'
print(pos_only(1))
#> a=1 b=2
@validate_call
def var_args(*args: int) -> str:
return str(args)
print(var_args(1))
#> (1,)
print(var_args(1, 2, 3))
#> (1, 2, 3)
@validate_call
def var_kwargs(**kwargs: int) -> str:
return str(kwargs)
print(var_kwargs(a=1))
#> {'a': 1}
print(var_kwargs(a=1, b=2))
#> {'a': 1, 'b': 2}
@validate_call
def armageddon(
a: int,
/,
b: int,
*c: int,
d: int,
e: int = None,
**f: int,
) -> str:
return f'a={a} b={b} c={c} d={d} e={e} f={f}'
print(armageddon(1, 2, d=3))
#> a=1 b=2 c=() d=3 e=None f={}
print(armageddon(1, 2, 3, 4, 5, 6, d=8, e=9, f=10, spam=11))
#> a=1 b=2 c=(3, 4, 5, 6) d=8 e=9 f={'f': 10, 'spam': 11}
最后一个 armageddon 综合了全部五种形态:仅位置参数 a、普通参数 b、可变位置参数 *c、仅关键字参数 d/e 以及可变关键字参数 **f,可以看到 *args 与 **kwargs 的元素也会按注解 int 逐一校验。
从源码看,这一能力来自 _generate_schema._arguments_schema(pydantic/_internal/_generate_schema.py):它通过 signature_no_eval 获取函数签名,将参数种类映射为 positional_only / positional_or_keyword / keyword_only 三种模式,并为 VAR_POSITIONAL 与 VAR_KEYWORD 单独生成元素 schema;未注解的参数在此处被显式回退为 Any(源码第 2019-2020 行)。
用 Unpack + TypedDict 注解 **kwargs
自 v2.10 起,Unpack 与 TypedDict 可用于注解函数的可变关键字参数(对应 PEP 692 规范):
from typing_extensions import TypedDict, Unpack
from pydantic import validate_call
class Point(TypedDict):
x: int
y: int
@validate_call
def add_coords(**kwargs: Unpack[Point]) -> int:
return kwargs['x'] + kwargs['y']
add_coords(x=1, y=2)
源码层面,_arguments_schema 会识别 Unpack[...] 注解并要求其内部必须是 TypedDict 类,否则抛出 PydanticUserError(错误码 unpack-typed-dict),同时会检查 TypedDict 字段与函数其他非仅位置参数是否重叠。
用 Field() 描述函数参数
Field() 函数同样可以配合装饰器为参数提供额外信息与校验。如果不使用 default / default_factory 参数,官方建议采用 Annotated 模式,让类型检查器把参数推断为必填;否则可以把 Field() 当作默认值使用,以“骗过”类型检查器认为参数有默认值:
from typing import Annotated
from pydantic import Field, ValidationError, validate_call
@validate_call
def how_many(num: Annotated[int, Field(gt=10)]):
return num
try:
how_many(1)
except ValidationError as e:
print(e)
"""
1 validation error for how_many
0
Input should be greater than 10 [type=greater_than, input_value=1, input_type=int]
"""
@validate_call
def return_value(value: str = Field(default='default value')):
return value
print(return_value())
#> default value
字段别名也可照常使用:
from typing import Annotated
from pydantic import Field, validate_call
@validate_call
def how_many(num: Annotated[int, Field(gt=10, alias='number')]):
return num
how_many(number=42)
通过 raw_function 访问原始函数
被装饰的原始函数可以通过包装函数的 raw_function 属性访问。当你信任输入参数、希望以最高效的方式直接调用函数(参见下文性能讨论)时非常有用:
from pydantic import validate_call
@validate_call
def repeat(s: str, count: int, *, separator: bytes = b'') -> bytes:
b = s.encode()
return separator.join(b for _ in range(count))
a = repeat('hello', 3)
print(a)
#> b'hellohellohello'
b = repeat.raw_function('good bye', 2, separator=b', ')
print(b)
#> b'good bye, good bye'
这个属性由 pydantic/_internal/_validate_call.py 中的 update_wrapper_attributes 设置:它使用 functools.wraps 保留 __name__、__qualname__、__doc__、__module__ 等元数据,再显式回填包装函数的 raw_function。测试 tests/test_validate_call.py 验证了 __doc__、__name__、__module__、__qualname__ 以及 inspect.signature 均与原函数一致。
异步函数
validate_call 同样适用于 async 函数:
import asyncio
from pydantic import PositiveInt, ValidationError, validate_call
@validate_call
async def get_user_email(user_id: PositiveInt):
# `conn` 是某个虚构的数据库连接
email = await conn.execute('select email from users where id=$1', user_id)
if email is None:
raise RuntimeError('user not found')
else:
return email
async def main():
email = await get_user_email(123)
print(email)
#> testing@example.com
try:
await get_user_email(-4)
except ValidationError as exc:
print(exc.errors(include_url=False))
"""
[
{
'type': 'greater_than',
'loc': (0,),
'msg': 'Input should be greater than 0',
'input': -4,
'ctx': {'gt': 0},
}
]
"""
asyncio.run(main())
# requires: `conn.execute()` 将返回 `'testing@example.com'`
注意 user_id: PositiveInt 在 await 之前就会被校验,负数 -4 直接触发 greater_than 错误。在源码中,异步包装由 update_wrapper_attributes 通过 inspect.iscoroutinefunction 检测并生成对应的 async def wrapper_function;而当 validate_return=True 时,ValidateCallWrapper._create_validators 会为协程专门生成一个 return_val_wrapper,先 await 协程再校验其返回值。测试 test_async_func 验证了这一行为。
与类型检查器的兼容性
由于装饰器完整保留了被装饰函数的签名(通过 functools.wraps),validate_call 与 mypy、pyright 等类型检查器兼容。不过受限于当前 Python 类型系统的能力,raw_function 等附加属性不会被类型检查器识别,访问时需要抑制报错(通常用 # type: ignore 注释)。
自定义配置
与 Pydantic 模型类似,装饰器的 config 参数可以传入自定义配置,例如允许任意类型的 ConfigDict(arbitrary_types_allowed=True):
from pydantic import ConfigDict, ValidationError, validate_call
class Foobar:
def __init__(self, v: str):
self.v = v
def __add__(self, other: 'Foobar') -> str:
return f'{self} + {other}'
def __str__(self) -> str:
return f'Foobar({self.v})'
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def add_foobars(a: Foobar, b: Foobar):
return a + b
c = add_foobars(Foobar('a'), Foobar('b'))
print(c)
#> Foobar(a) + Foobar(b)
try:
add_foobars(1, 2)
except ValidationError as e:
print(e)
"""
2 validation errors for add_foobars
0
Input should be an instance of Foobar [type=is_instance_of, input_value=1, input_type=int]
1
Input should be an instance of Foobar [type=is_instance_of, input_value=2, input_type=int]
"""
除了 arbitrary_types_allowed,任何 ConfigDict 支持的选项(如 strict、str_strip_whitespace、extra 等)都可在此使用。值得补充的是:若配置了 defer_build=True,ValidateCallWrapper 会推迟 schema 的构建,直到首次调用时才在 __call__ 中触发 _create_validators()(源码第 87-90、133-136 行),这是一种惰性构建优化。
扩展:将参数校验与函数调用分离
在某些场景下,把参数校验与函数调用本身分离开是很有用的——尤其是当目标函数代价高昂、耗时较长时。官方文档给出了如下工作区模式:
from pydantic import validate_call
@validate_call
def validate_foo(a: int, b: int):
def foo():
return a + b
return foo
foo = validate_foo(a=1, b=2)
print(foo())
#> 3
先通过 validate_foo(a=1, b=2) 完成参数校验并闭包捕获已校验的参数,再在真正需要时调用返回的 foo()——校验只发生一次,后续调用不再有任何校验开销。
源码层面的工作方式
validate_call 的实现虽然只有短短几行入口,但背后是一整套与模型相同的 schema 生成与校验流水线:
- 类型检查:
_check_function_type(validate_call_decorator.py)只接受LambdaType | FunctionType | MethodType | partial(见 pydantic/_internal/_generate_schema.py 的ValidateCallSupportedTypes别名)。以下情况会抛出PydanticUserError(错误码validate-call-type):- 内建函数(如
breakpoint)不受支持; @classmethod/@staticmethod装饰器必须放在@validate_call之上(先写@classmethod再写@validate_call),顺序颠倒会报错;- 直接对类使用会报错,官方建议把
@validate_call加到__init__或__new__上(测试 test_validate_class 验证了A('5').x == 5); - 对可调用实例使用会报错,官方建议显式装饰其
__call__方法; partial的底层函数也必须属于受支持类型,例如validate_call(partial(list))会被拒绝。
- 内建函数(如
- Schema 生成:
ValidateCallWrapper._create_validators(pydantic/_internal/_validate_call.py)使用GenerateSchema为整个函数签名生成core_schema.CallSchema(入口见 _generate_schema.py 的 _call_schema),再经create_schema_validator创建__pydantic_validator__;当validate_return=True时,额外为返回注解生成一个返回校验器。 - 调用校验:每次调用时,实参被包装为
pydantic_core.ArgsKwargs(args, kwargs)交给__pydantic_validator__.validate_python(源码第 133-141 行),校验结果直接作为返回值(或先经返回校验器)。由于校验基于 pydantic-core 的 Rust 实现,参数检查本身非常高效。 - 命名空间解析:构造时还会通过
NsResolver与parent_frame_namespace解析函数定义处的命名空间,确保带字符串注解、TypeVar 等场景也能正确求值(见测试 test_eval_namespace_basic)。
限制与性能
校验异常
目前校验失败时会抛出标准的 Pydantic ValidationError(详见 模型错误处理),这一点对“缺少必填参数”同样成立——通常 Python 会抛出 TypeError,但使用 validate_call 后,缺失必填参数会以 missing_argument / missing_keyword_only_argument 错误类型的 ValidationError 报告(见测试 test_args 与 test_kwargs)。错误信息会指明被拒绝的参数与值;如果需要连同周围 trace 上下文一并记录这些细节,可配合 Logfire 集成 使用,参见排查校验错误。
性能
官方文档明确说明:虽然对被装饰函数的检查(schema 生成)只执行一次,但每次调用包装函数相比直接调用原始函数仍存在性能开销。多数场景下这种开销几乎没有可感知的影响,但请务必理解:validate_call 不是强类型语言中函数定义的等价物或替代品,也不可能是。若某段热路径对性能敏感,可借助 raw_function 绕过校验直接调用原始函数。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



