第一次把项目从 Flask 迁到 FastAPI 的时候,我根本没把“类型注解”当回事。脚本语言嘛,写了这么多年 Python,哪次不是自己控制类型。结果迁移第一周就吃了大亏:一个接口的查询参数忘记写类型注解,Swagger 文档里参数列表直接消失,前端同事拿着文档问我“这个接口到底要传什么”,我翻代码翻了二十分钟。也就是从那次开始,我才真正意识到 FastAPI 不是另一个 Web 框架,它是一套以类型注解为核心引擎的工具链。这篇是这个 FastAPI 系列的第一篇,专门讲 Python 类型。别觉得它基础,你不把它搞透,后面的路径参数、请求体、响应模型、依赖注入都会学得稀里糊涂。这篇适合刚接触 FastAPI、或者写了几年 Python 但从不写类型注解的朋友。
1. 先别急着装框架,把类型注解这件事的意义想透
1.1 FastAPI 为什么靠类型“吃饭”
FastAPI 官网自我介绍的几个关键词:现代、快速、高效。很多人把这当成营销话术,直到真正用起来才会发现,它的“高效”并不是指运行性能比谁都猛,而是指开发效率。
传统 Python Web 框架,比如 Flask,写一个接口是这样的思路:路由和函数一对一绑定,参数从 request 里手动取,类型靠自己转换,出错自己 try except。FastAPI 换了一套玩法:你在函数签名里写类型注解,框架在运行时读取这些注解,替你完成参数提取、类型转换、数据校验、序列化、文档生成。整套流程下来,你少写的不只是几行代码,而是一整套以前必须手写的胶水层。
这套玩法背后是三个组件咬合在一起:Starlette 提供 ASGI 异步能力,Pydantic 提供数据校验,而 Python 的类型注解就是连接它们的“通用语言”。把话说直白一点:不会写类型注解,FastAPI 就等于废了一半。这也是为什么 Flast 和 FastAPI 对比的文章里,几乎都会提到“类型系统”这个分水岭。
1.2 类型注解的三个受众:人、静态检查、运行时框架
在开始写任何 FastAPI 代码之前,得先把“类型注解是给谁看的”这个问题想清楚。我总结了三个受众:
- 给同事和未来的自己看。函数签名里
username: str比任何注释都直接,不用翻函数体就知道该传什么。 - 给静态检查工具看。mypy、Pyright、Pylance 这类工具能在代码运行之前就抓到你传错类型的问题。
- 给运行时框架看。这一点是 FastAPI 和传统 Python 框架最大的区别。普通 Python 里,注解默认只是存在
__annotations__里的元数据,不影响程序执行;而在 FastAPI 里,注解会被运行时读取,变成真正的约束规则。
这第三点很多人一开始反应不过来。我见过有同事把 FastAPI 的路由函数写成完全不写注解的样式,结果接口全部变成“什么参数都不认识”的裸接口,Swagger 文档上参数列表一片空白,还以为是框架坏了。不是,是你把类型信息这个“电源”拔了。
1.3 准备环境:一个最小可运行的 FastAPI 项目
既然是系列第一篇,还是把环境说清楚。建议用虚拟环境,Python 版本选 3.9 以上,最好 3.10 或 3.11,方便用上一些比较新的类型语法。安装就两条命令:
pip install fastapi pip install "uvicorn[standard]"这里补充一句很多人搜过的“python安装”问题:如果你用的是系统自带的 Python,尤其 Linux 和 macOS 自带的那份,建议不要直接把包装进系统环境,容易把环境搞乱。用python -m venv venv建一个隔离环境,再 activate 进去。Windows 上装 Python 记得勾选 Add Python to PATH,不然命令行敲 python 会提示找不到命令。
装完后可以快速验证环境。跑一个最小项目,能启动、能访问,说明后面的类型玩法都能跟上。
from fastapi import FastAPI app = FastAPI() @app.get("/hello/{name}") def hello(name: str) -> dict: return {"message": f"hello {name}"}启动命令:uvicorn main:app --reload。这个文件里已经有类型注解了,name: str决定了路径参数/hello/abc的行为。你可以试试访问/hello/123,返回的还是字符串"123",而不是数字 123,因为注解是 str,不会帮你转 int。这就引出了下一节的内容。
2. 内置类型注解:最基础也最容易翻车的那几个
2.1 函数签名里的 int、str、float、bool
先把最朴素的写法过一遍:
def add(x: int, y: int) -> int: return x + y冒号后面跟的是参数类型,箭头后面是返回值类型。这三个位置写清楚之后,调用方在编辑器里能直接看到提示,mypy 也能做静态检查。不过要注意,Python 在运行时不会因为你传了字符串进去就报错,add("1", "2")照样会执行,结果变成字符串拼接。类型注解在这里只是“声明”,不是“强制”。
这和热词里的“python类型转换”是两码事。类型转换是指int("42")、float("3.14")这些主动行为,是把一个值变成另一个类型;而类型注解只是给值贴了一个标签,说明它“应该”是什么类型。很多人刚学 FastAPI 时把这两件事混在一起,总觉得“我写了 int 注解,框架就该帮我强转”。其实框架确实会转换,但那是 FastAPI 的运行时逻辑帮你做的,不是类型注解本身的功劳。这个区别会在第五节展开讲。
2.2 bool 是 int 的“私生子”
内置类型里最阴险的是 bool。Python 里bool是int的子类,True本质上就是1,False就是0,所以isinstance(True, int)返回的是True。这带来一个非常隐蔽的问题:
def judge(flag: bool) -> str: return "yes" if flag else "no" judge(1) # 运行时不报错,返回 "yes" judge("ok") # 运行时也不报错,返回 "yes"如果你用 mypy 检查,judge(1)会在严格模式下被标红;但如果你的代码没有接静态检查,这个错误就只有等线上出 bug 了。更麻烦的是 FastAPI 处理查询参数的时候,如果你定义一个flag: bool的参数,字符串"false"到底会被正确解析成 False,还是被当成 True,取决于框架的解析规则。Pydantic 对 bool 的解析有一套明确清单:"true"、"1"、"yes"、"on"都算 True,"false"、"0"、"no"、"off"都算 False,大小写不敏感。所以在 FastAPI 里?flag=false得到的是 False,不会像原生 Python 里那样因为“非空字符串是真值”而解析成 True。这一点建议实测一下,很多人第一次接触都在这儿懵过。
2.3 注解不会阻止你犯错,它只是把错误提前暴露
既然运行时不强制,那类型注解到底有什么用?我的理解是:它把错误从“运行时”尽量提前到“写代码时”。
举个很常见的场景。接口 A 返回的数据里有个字段叫created_time,你写的时候没注意,拼成了create_time。原生 Python 不报错,返回照样 200,前端拿不到值,排查半天才发现是字段名错了。如果你用了 FastAPI 的响应模型,并且声明了字段类型,返回结构会被比对,多余的字段会被过滤掉,编辑器里也会提示字段名不存在。错误在写代码那一刻就被暴露出来,而不是等到联调。
所以类型注解的价值不是“让 Python 变成静态语言”,而是“让工具能帮你兜底”。这个心态摆正了,后面学 Pydantic 模型、学响应模型,都不会觉得是在学额外的东西。
3. 容器类型与 typing 模块:list、dict、Optional、Union 的正确打开方式
3.1 Python 3.9 前后的写法差异
早期 Python 想在注解里表达“一个整数列表”,必须从typing模块导入List,写成List[int]。从 Python 3.9 开始,内置类型本身支持泛型,可以直接写list[int]。我整理了一张对照表:
| 语义 | Python 3.8 及之前 | Python 3.9+ |
|---|---|---|
| 整数列表 | typing.List[int] | list[int] |
| 字符串到整数的映射 | typing.Dict[str, int] | dict[str, int] |
| 字符串集合 | typing.Set[str] | set[str] |
| 定长元组 | typing.Tuple[int, str] | tuple[int, str] |
| 变长元组 | typing.Tuple[int, ...] | tuple[int, ...] |
老写法在新版本里还能用,typing.List这类仍然存在,但从 3.9 开始 PEP 585 已经允许内建泛型。新项目我建议直接写小写版本,少一次 import,代码也清爽。如果项目里还有大量旧写法,也不用焦虑,二者共存没问题,只是注意别在同一份代码里混着用——风格统一比风格先进更重要。
3.2 Optional 和 Union:也许你根本用不到 Optional
Optional[int]的定义是Union[int, None],也就是说它只是Union的一个更具体的别名,表示“这个值可能是整数,也可能是 None”。这里最容易出现的误解有两个。
第一个误解是,很多人以为Optional[int]表示“参数可以不用传”。其实它和参数有没有默认值完全没关系。你写def f(x: Optional[int]),x 仍然是一个必传参数,只不过它既能接受 int,也能接受 None。想让参数可省略,需要给它一个默认值,比如def f(x: Optional[int] = None)。
第二个误解是 Union 的写法。Python 3.10 开始允许用|操作符,int | None和Union[int, None]等价,list[int] | None也合法。在 FastAPI 的查询参数场景里,最标准的写法是:
@app.get("/search") def search(q: str | None = None): if q is None: return {"result": "no keyword"} return {"result": q}这里q: str | None = None表达两层意思:类型上可能为 None,默认值也是 None。FastAPI 看到默认值是 None,就知道这是一个可选查询参数,文档里会把它标记为非必填。如果你写成q: str = None,mypy 会立刻报错,因为默认值 None 和类型 str 不匹配;FastAPI 运行时不一定会崩,但这是明显的类型错误,别保留这种写法。
3.3 其他常用类型:Callable、Any、NoReturn、Literal
除了容器类型,日常开发里还有几个高频类型值得掌握:
Any:逃逸阀门。x: Any表示“我不关心类型”,静态检查会直接放弃对这个值的追踪。能用但别滥用,一处 Any 就是一个类型盲区。Callable[[int, str], bool]:描述“接受一个 int 和一个 str、返回 bool”的函数类型。写装饰器、回调参数时会用到,FastAPI 的依赖注入机制内部也大量依赖 Callable,不过日常写接口很少直接面对它。NoReturn:表示函数一定抛异常或永远不返回,比如def raise_error() -> NoReturn。Literal["a", "b"]:把取值范围锁死在几个字面量上。用来做接口里的状态字段非常好用,例如status: Literal["active", "inactive", "pending"]。它和 FastAPI 里的枚举可以互相替代,具体取舍我在第五节再展开。
还有一个容易被忽略的点:dict和list的注解不要只写到类型名。只写list等价于list[Any],等于没写;同理dict至少写成dict[str, str]或dict[str, Any],别图省事。
4. 自定义数据结构:从 class 到 dataclass 再到 Pydantic
4.1 类本身就能当类型:给数据一个名字
前面讲的都是内置类型,实际接口里更多是复合结构。比如一个用户对象,有名字、年龄、邮箱,如果你没有自定义类型,就得写成dict[str, object]或者干脆dict,这种注解约等于没有。
最简单的做法是定义一个类:
class User: def __init__(self, name: str, age: int): self.name = name self.age = age def greet(user: User) -> str: return f"{user.name} is {user.age} years old"在这个例子里,User就是一个类型。greet的形参被限定为 User 实例,传入字典会过不了静态检查。但这个写法有两个痛点:一是要手写__init__,重复劳动;二是没有任何运行时校验,你可以在代码里硬塞一个User("张三", "二十五"),年龄字段变成了字符串,没人拦你。
4.2 dataclass:自动补全样板代码,但校验仍然缺失
Python 3.7 引入 dataclass 之后,普通数据类的写法简化了很多:
from dataclasses import dataclass @dataclass class User: name: str age: int__init__、__repr__全自动生成,代码量一下子少了。而且 dataclass 支持字段默认值,例如age: int = 0。如果你想加校验,得自己在__post_init__里写逻辑,比如判断 age 是不是 int,不是就抛异常。这属于“自己造轮子”。每个字段都要手动校验,字段一多,代码立刻变得啰嗦。
FastAPI 其实也支持用 dataclass 作为请求体模型,但校验、序列化、文档生成的完整度远不如 Pydantic。所以我个人在 FastAPI 项目里几乎不用 dataclass 作为接口模型,它更适合写内部工具代码。
4.3 Pydantic BaseModel:FastAPI 选中它的原因
Pydantic 是 FastAPI 的核心依赖,它的BaseModel看起来和 dataclass 差不多,但内功完全不同:
from pydantic import BaseModel class UserIn(BaseModel): name: str age: int = 0 tags: list[str] = []区别体现在三件事上。
第一,运行时校验。你传UserIn(name="张三", age="abc")会直接抛 ValidationError,告诉你 age 不是合法整数。更贴心的是 Pydantic 会在合理范围内做类型转换,比如age="25"会被转成整数 25,这是它显式设计的宽容行为。如果你希望严格模式,可以在 Pydantic v2 里配置model_config = ConfigDict(strict=True)。
第二,JSON 序列化。Pydantic v2 里用model_dump()和model_dump_json(),v1 是dict()和json()。嵌套模型也能自动序列化,这在 FastAPI 返回响应时几乎是万能钥匙。
第三,OpenAPI 文档生成。FastAPI 会扫描 Pydantic 模型的字段、类型、默认值、描述,自动生成 Swagger UI 的 schema。这个能力是 dataclass 和普通 class 给不了的。
提示:Pydantic v2 和 v1 在 API 上有不少差异。新项目直接用 v2,安装 fastapi 时会自动带上兼容版本。网上搜到的教程如果是
class Config: orm = True、.dict()这种老写法,大概率是 v1,注意甄别。
4.4 三种数据模型怎么选,看一张表就够了
| 能力 | 普通 class | dataclass | Pydantic BaseModel |
|---|---|---|---|
自动__init__ | 需要手写 | 自动生成 | 自动生成 |
| 运行时数据校验 | 无 | 需要自己写 | 内置丰富校验 |
| 类型转换 | 无 | 无 | 有 |
| JSON 序列化 | 需要手动实现 | 需配合dataclasses.asdict | 原生支持 |
| FastAPI 文档联动 | 无 | 有限支持 | 完整支持 |
| 适合场景 | 内部逻辑对象 | 内部工具、配置数据 | 接口入参、出参 |
我的选型经验是:FastAPI 接口的请求体和响应体一律用 BaseModel;内部算法、函数之间的中间数据结构用 dataclass;只有很轻量的对象才用普通 class。别硬把一种模型套在两个场景上,否则要么校验缺失,要么序列化别扭。
5. FastAPI 是如何“消费”这些类型信息的
5.1 路径参数:同一个注解,接口行为完全不一样
先看一个最小但关键的例子:
from fastapi import FastAPI app = FastAPI() @app.get("/users/{user_id}") def get_user(user_id: int): return {"user_id": user_id, "type": type(user_id).__name__}请求/users/42,返回{"user_id": 42, "type": "int"}。注意,这个 42 是从 URL 字符串里解析出来的,FastAPI 根据user_id: int把它转成了整数。如果你访问/users/abc,不会像原生 Python 那样抛 ValueError 然后 500,而是返回一个标准的 422 验证错误响应,格式统一,客户端能直接解析。
把注解从 int 换成 str 试试,/users/abc又变成合法的了。这里可以很直观地感受到:类型注解在 FastAPI 里就是规则本身,你改一个注解,接口的约束就变了。第一次意识到这一点的时候,我半开玩笑地想过:这哪里是“类型注解”,分明是搞了一个迷你 DSL。
5.2 查询参数:默认值就是参数“身份标志”
FastAPI 判断一个参数是路径参数、查询参数还是请求体,规则非常简单粗暴:
- 参数名和大括号里的路径变量一致,就是路径参数;
- 参数是 Pydantic 模型,或者用
Body等特殊依赖显式声明,就是请求体; - 剩下的是查询参数。
查询参数里还有一个隐藏规则:看默认值。有默认值的参数是可选的,没有默认值的参数是必填的。比如:
@app.get("/search") def search(q: str, page: int = 1, size: int = 10): return {"q": q, "page": page, "size": size}q没有默认值,所以访问/search会 422,必须带?q=...。page和size有默认值,不传也能跑,默认分别是 1 和 10。
再配合 Optional 的含义来看:q: str | None = None是“允许缺省、缺省时是 None”;q: str = None虽然运行时 FastAPI 也能推断出可选,但类型上自相矛盾。我在前面也强调过,该用str | None = None就别偷懒。很多老教程里写q: str = None,那是历史遗留写法,现在你把它当成反例记住就好。
5.3 请求体与响应模型:类型就是接口契约
接口最难维护的其实是请求体和响应体的结构。FastAPI 用类型把这个结构固定了下来:
from pydantic import BaseModel class ItemIn(BaseModel): name: str price: float tax: float | None = None class ItemOut(BaseModel): name: str price: float @app.post("/items", response_model=ItemOut) def create_item(item: ItemIn): return {"name": item.name, "price": item.price, "internal": "秘密字段"}路径上 POST 请求的 JSON body 会被 Pydantic 解析成ItemIn实例,字段多余、类型不对、缺了必填字段,都会返回标准 422 错误。响应那边更有意思:虽然函数返回了一个带internal字段的字典,但因为response_model=ItemOut只声明了 name 和 price,FastAPI 会在返回前把多余字段过滤掉。客户端拿到的永远是契约内定义的字段,不会意外泄露内部数据。
这就是我理解的“类型即契约”:后端定义模型,前端看 OpenAPI 文档生成代码,双方以同一套类型结构对话。字段改名、类型变化,都会在联调前被工具暴露出来,而不是靠开会靠猜。
5.4 枚举与字面量输出:热词里的“枚举类型转换为字符串”是怎么回事
热词榜里有一个非常具体的问题:“枚举类型转换为字符串”。FastAPI 返回 Pydantic 模型时,枚举字段会自动序列化成它的值,这正是很多人到处搜的原因。看这个例子:
from enum import Enum class UserStatus(str, Enum): ACTIVE = "active" DISABLED = "disabled" class UserOut(BaseModel): name: str status: UserStatus当 FastAPI 把UserOut序列化成 JSON 时,status会变成字符串"active"或"disabled",而不是类似UserStatus.ACTIVE这种对象。如果你定义的是普通Enum(不继承 str),序列化时同样会输出.value,也就是枚举成员的值;但普通 Enum 成员在代码里不能直接和字符串比较,所以接口模型里我习惯用class UserStatus(str, Enum),一举两得:既能直接和"active"比较,序列化又自然。
如果你不想为这种小场景单独建一个枚举类,也可以用Literal:
from typing import Literal class UserOut(BaseModel): status: Literal["active", "disabled"]Literal 和枚举在 FastAPI 里的 OpenAPI 文档都会展示允许的取值列表,功能上很接近,差别主要在表达力:枚举是“给一组取值命名”,Literal 是“直接列出允许值”。字段多、取值语义复杂的用枚举,简单两三态用 Literal,看个人习惯。
5.5 自动文档:类型注解带回来的免费福利
最后说一说 FastAPI 让我最舒服的一点:/docs 页面。你把类型注解和 Pydantic 模型写好后,打开http://127.0.0.1:8000/docs,Swagger UI 自动生成,每个接口的参数、类型、默认值、是否必填、响应结构、示例,全部摆在那里。不需要你额外写一行文档注释,全靠类型信息推导出来。
而且这个文档不是静态的。前端调试、给第三方对接方发接口文档时,直接丢一个 OpenAPI JSON 链接出去即可。参数类型变了,文档立刻变。相比之下,Flask 项目里用注释手写的接口文档经常和实际代码脱节,写完就过期,这是我当初迁移到 FastAPI 的最大推动力之一。
6. 实操中的类型注解避坑与习惯养成
6.1 可变默认参数:list=[] 这种写法要不得
这个坑我在普通 Python 代码里踩过,在 FastAPI 项目里也见人踩过:
def add_item(item: str, storage: list[str] = []) -> list[str]: storage.append(item) return storagePython 的默认参数在函数定义时只求值一次,所以每次调用如果没传storage,用的都是同一个列表对象。第一次调用存进去的元素,第二次调用还在。正确写法是:
def add_item(item: str, storage: list[str] | None = None) -> list[str]: if storage is None: storage = [] storage.append(item) return storage延伸到 Pydantic 模型里,同样的问题有专门的解法。不要写tags: list[str] = [],而是写:
from pydantic import Field class Item(BaseModel): name: str tags: list[str] = Field(default_factory=list)这样每个 Item 实例都会用自己的新列表,互不污染。这个细节属于“报错不会提示、线上才会炸”的典型,值得在习惯里提前规避。
6.2 用 TypeAlias 给复杂类型起名字
当你的类型注解变得一层套一层,函数签名会快速变得没法看:
def load_users() -> dict[str, list[dict[str, str | int]]]: ...这种签名第一眼看过去,没人精神不恍惚。可以用 TypeAlias 给复杂类型一个名字:
from typing import TypeAlias UserMap: TypeAlias = dict[str, list[dict[str, str | int]]] def load_users() -> UserMap: ...Python 3.12 又提供了更简洁的语法:type UserMap = dict[str, list[dict[str, str | int]]]。这对 FastAPI 项目特别有用,尤其是响应模型层层嵌套的场景,给一个别名之后,函数签名读起来就像是在读业务文档。
6.3 fromfutureimport annotations:有用,但 FastAPI 场景里要留个心眼
from __future__ import annotations会把所有注解变成字符串,延迟求值。它的好处是可以解决类内部自引用、避免导入顺序问题。但在 FastAPI 里有个特殊点:框架必须在运行时读取注解来做校验,如果注解全是字符串,Pydantic 和 FastAPI 得靠get_type_hints()把字符串解析回真正的类型。简单场景没问题,但遇到复杂的泛型、嵌套模型,解析失败会报一些很难看懂的错。
我个人的建议是:FastAPI 项目里我一般不加这个 future import,除非确有必要,比如和某些第三方库的兼容问题。加之前先跑一遍接口测试。Pydantic v2 对延迟注解的支持比 v1 好很多,但没必要为了省一个 import 去赌边界情况。
6.4 和 mypy、编辑器的配合:让类型注解真正跑起来
写类型注解如果不用静态检查工具,效果起码打五折。我的建议是:
- 代码编辑器配 Pylance 或 Pyright 插件,日常写代码实时看到类型问题;
- 项目里配 mypy 做 CI 检查,配置放在
pyproject.toml:
[tool.mypy] python_version = "3.11" strict = true ignore_missing_imports = true- 遇到类型推断不出来的地方,用
reveal_type(x)调试,mypy 会打印出它推断到的类型,比肉眼猜强太多。
我见过不少项目,类型注解写了,但从不跑检查,等于写了一堆“装饰性代码”。静态检查工具才是让这些注解发挥价值的另一半。
6.5 常见卡点速查表
最后把我遇到比较多的几种类型问题整理成一张表,方便随时翻:
| 写法 | 问题 | 建议 |
|---|---|---|
list、dict不带泛型参数 | 等同于list[Any],约等于没写 | 写list[int]、dict[str, int] |
q: str = None | 类型声明和默认值矛盾,mypy 报错 | 写 `q: str |
Optional[int] = 10 | 类型允许 None,默认值却是 10,语义混乱 | 明确默认值到底是什么 |
def f(x: list = []) | 可变默认参数共享同一对象 | 用None兜底或Field(default_factory=list) |
不加response_model | 响应字段不可控,可能泄露内部字段 | 接口一律声明response_model |
把Any当万能药 | 类型盲区蔓延,框架白打工 | 尽量用精确类型,Any只放在边界 |
做这个系列之前,我把之前的 FastAPI 项目翻出来重读了一遍,发现当时最耗时的不是在写路由,而是在反复核对数据类型、手写校验、维护文档。类型注解这套东西,初看是给框架用的,用久了会发现它最大的受益者其实是自己——代码可读性、改动的信心、联调的速度,全都提上来了。下一篇我会接着讲 FastAPI 的请求参数与校验细节,把路径参数、查询参数、请求体放到真实接口里一个个过。如果你也是那种“写 Python 不写注解”的老手,建议先把一个小接口加上类型注解跑一遍,那种感受会非常直观。