最近我把手头一个知识库工具的数据层整个换了。原来的代码是 SQLAlchemy 加 Pydantic 两套模型并行维护,时间一长,两边字段改漏是常事——接口文档返回的字段和数据库列名对不上,前端催着改,后端调试改到半夜,修起来别提多疼。换成 SQLModel 之后,这个问题从根上消失了,一张模型定义同时管住了数据库表和 API 校验,再也不用在 _model.py 和 _schema.py 两个文件之间反复横跳。
这篇文章不是帮你背 API 文档,而是把 SQLModel 最常用的那批动作——定义表、建引擎、开会话、增删改查、联表查询、和 FastAPI 粘在一起——按真实业务发生的顺序串一遍。每个环节我都会给代码、讲为什么这么写、附上我踩过的坑。如果你正在用 FastAPI 做中小型项目,又不想在 ORM 选型上折腾,这篇文章应该能帮你省下不少时间。
1. 为什么我最终把数据层换成了 SQLModel
先交代一下背景。我在重写的是一个轻量知识库服务:用户注册登录、上传文档、文档被切块后存进数据库,同时后端要调外部大模型 API 做向量化和问答。因为外部接口需要 API Key,调试时常碰到401 unauthorized: incorrect api key provided这类报错,那属于网关授权层的问题,和数据持久化关系不大——但如果你同时又要管用户表、文档表、切块表,那就必须有一个顺手的数据层工具,否则排查了半天 401,回头又发现会话记录根本没写进去,双线踩坑。
当时我考察过几个方案:
| 方案 | 优点 | 我放弃的原因 |
|---|---|---|
| 裸 SQLAlchemy | 能力强,生态熟 | 模型和 Pydantic Schema 要写两套,小型项目里维护成本偏高 |
| Django ORM | 自带迁移和管理后台 | 为了一个知识库服务搬 Django,重了 |
| Tortoise ORM | 轻量异步友好 | 生态相对薄,FastAPI 下要自己处理的事情略多 |
| SQLModel | 单模型复用,FastAPI 原生契合 | 相对较新,但核心 API 已经很稳 |
SQLModel 的底层就是 SQLAlchemy Core 加 Pydantic。它做的事情说白了:你在一个类里同时声明数据库表的列结构、Python 类型校验规则、以及接口序列化结构。声明完之后,这张表既可以上数据库,也可以直接塞给 FastAPI 当响应模型。
这个设计对中小项目特别友好。比如注册接口,同一个UserCreate模型既能用来解析请求体,又能顺便帮你做字段校验;同一个User模型既能建表,又能作为查询后的返回结构。少了一半样板代码,眼睛也不会因为来回对比两个文件而发酸。
如果你之前用过 SQLAlchemy,上手 SQLModel 几乎没有额外学习成本。它保留了熟悉的create_engine、Session、select这些概念,只是把声明模型的方式改得更 Pydantic。如果你之前只用 Pydantic,那更好,写模型就像写一个带类型注解的类。
2. 从一张表开始:模型定义里的那些隐藏细节
2.1 一个最小模型的完整拆解
先看最简单的一张用户表。这是最常见到的定义方式:
from datetime import datetime, timezone from typing import Optional from sqlmodel import SQLModel, Field, create_engine class User(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) username: str = Field(index=True, unique=True, min_length=3, max_length=32) email: str = Field(index=True, unique=True) hashed_password: str is_active: bool = Field(default=True) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))注意到两点。第一,table=True这个参数非常重要:加了它,User才是一张真实的数据表;不加,它就只是一个普通的 Pydantic 模型,通常用来做 API 的请求体或响应结构。同一个SQLModel基类,通过这个开关分成"表模型"和"非表模型"两个阵营,这也是理解后续所有 API 的核心。
第二,Optional[int]配上primary_key=True是官方推荐的写法。因为自增主键在 insert 之前并不存在,把它声明成可选,才能在建对象时不传这个字段也不会报类型错误。我见过不少人在这里直接用int,结果初始化时User(username="xxx")直接触发类型校验失败,卡了半天才发现是主键类型写紧了。
2.2 Field 里常用的选项不是装饰,是约束
Field是 SQLModel 里把"数据库约束"和"Pydantic 校验"合并成一处的关键入口。我常用的几组参数:
| 参数 | 数据库层作用 | API 校验层作用 |
|---|---|---|
primary_key=True | 主键索引 | 标记必填/忽略 |
index=True | 建普通索引 | 无 |
unique=True | 唯一约束 | 无 |
nullable=False | 非空约束 | 标记必填 |
min_length/max_length | 无 | 字符串长度校验 |
default/default_factory | 默认值/表达式默认值 | 未传参时填充 |
foreign_key="table.id" | 外键约束 | 无 |
sa_column | 直接透传 SQLAlchemy 列定义 | 无 |
这里特别注意min_length和max_length,它们默认会被 FastAPI 用来做请求体验证。也就是说,同一个字段,既约束了数据库的存储规则,又约束了接口的入参格式,真正做到一处声明、多处生效。
2.3 表名、列名那些容易翻车的地方
默认情况下,类名User会直接作为表名,字段名hashed_password会直接作为列名。但如果你接的是一套历史遗留数据库,表名、列名很可能和你新建的模型对不上。SQLModel 提供两个兜底方案:
class User(SQLModel, table=True): __tablename__ = "sys_users" id: Optional[int] = Field(default=None, primary_key=True) full_name: str = Field(sa_column=Column("full_name", String(64), nullable=False))当sa_column出现时,SQLModel 就不再尝试自动推导列,完全交给 SQLAlchemy 的Column定义去控制。这意味着你能用上 SQLAlchemy 最底层的所有能力,包括自定义类型、注释、表空间这类高级配置。
建表本身很简单,一行:
engine = create_engine("sqlite:///knowledge.db", echo=True) SQLModel.metadata.create_all(engine)SQLModel.metadata就是全局的表注册表,所有table=True的模型都在这里。初学阶段直接建表没问题,但项目一旦变复杂,还是建议用 Alembic 做迁移,后面有一章我会专门说。
3. 会话、增删改查与常用 API 的完整生命周期
3.1 一个引擎管所有连接
create_engine在 SQLModel 里和 SQLAlchemy 是同一个函数。你可以理解为项目与数据库之间的连接池入口:
from sqlmodel import create_engine sqlite_url = "sqlite:///knowledge.db" engine = create_engine( sqlite_url, echo=False, connect_args={"check_same_thread": False}, # SQLite + FastAPI 常用 )echo=True会把 SQL 语句打印到控制台,调试阶段强烈建议打开,你能直观看到每次查询生成了什么 SQL。上线前再关掉,否则日志量会淹没业务日志。
生产环境用 PostgreSQL 时,建议直接接官方连接串postgresql+psycopg://user:pass@host:5432/dbname。SQLite 只适合本地开发和单机小工具,多人并发写的时候锁竞争会很麻烦。
3.2 Session:事务的边界就是代码块
Session 是操作数据库的最小工作单元。最稳妥的打开方式是用上下文管理器,保证异常时自动回滚、正常时自动关闭:
from sqlmodel import Session, select def create_user(username: str, password: str) -> User: with Session(engine) as session: user = User(username=username, hashed_password=password) session.add(user) session.commit() session.refresh(user) return usercommit提交事务,refresh则从数据库重新加载一次该对象。为什么要 refresh?因为自增主键、数据库默认值等字段是在 insert 后才真正生成的,如果不刷新,user.id可能会是None,后续拿它去关联其他表就会出现隐晦的 bug。
3.3 查询:select 是主力,但返回值形态要分清
SQLModel 的查询入口是session.exec(select(...)),注意不是session.execute,虽然二者底层相似,但 SQLModel 的exec会帮你做Result的类型映射,取出来的对象会带上正确的 Python 类型。
常用查询写法:
from sqlmodel import select, Session def get_user_by_username(session: Session, username: str) -> User | None: stmt = select(User).where(User.username == username) return session.exec(stmt).first() def list_active_users(session: Session) -> list[User]: stmt = select(User).where(User.is_active == True).order_by(User.created_at.desc()) return list(session.exec(stmt).all()).first()返回单条或 None,.all()返回全部,.one_or_none()要求结果要么一条要么没有,多了会抛异常。没有.one()的情况下要注意:如果预期结果唯一但是数据库里因为脏数据出现两条,.first()会静默地拿第一条,掩盖数据问题。我在线上排查过一个重复用户名数据,就是因为代码里用了.first()没发现冲突,最后 unique 索引报警才揪出来。
3.4 更新与删除:属性改完要记得 commit
更新操作有两种风格。
风格一:先查出对象,改属性,commit
user = session.exec(select(User).where(User.id == user_id)).first() if user: user.is_active = False session.add(user) session.commit()这样写最直观,而且在改的同时还能拿到对象上的校验逻辑。注意session.add对已存在对象是标记"已修改",不是新增。
风格二:SQL 层面批量更新
SQLModel 高版本提供了表级便捷方法:
from sqlmodel import update stmt = update(User).where(User.username == "admin").values(is_active=False) session.exec(stmt) session.commit()批量更新不需要先把数据加载到内存,对大表更友好。删除同理:
from sqlmodel import delete stmt = delete(User).where(User.id == 123) session.exec(stmt) session.commit()或者查出来再删:
user = session.exec(select(User).where(User.id == 123)).first() if user: session.delete(user) session.commit()我的建议是:单对象操作走风格一,批量操作走 SQL 快捷方法。混着用没问题,只要记得每个事务结束时都要commit,否则事务回滚后一切白干。
3.5 事务的原子性边界
一个 Session 内部可以包含多个操作,它们属于同一个事务,任何一个失败,其他已执行到一半的操作也会一起回滚。这在"先写用户、再写积分记录"这种场景很关键:
with Session(engine) as session: user = User(username="alice", hashed_password="...") session.add(user) session.commit() # 这里一旦提交,事务就结束了 score = Score(user_id=user.id, points=10) session.add(score) session.commit()上面这个写法里两个操作是"两个独立事务"。如果第二次 commit 失败,第一次的用户已经写进去了,业务上就会留下一个没有积分的用户。需要原子性时应该把所有动作放在同一个事务里,只在最后 commit 一次:
with Session(engine) as session: user = User(username="alice", hashed_password="...") session.add(user) # 不 commit,先让 user.id 在 flush 时生成 session.flush() score = Score(user_id=user.id, points=10) session.add(score) session.commit()flush会把 SQL 发到数据库但不提交,这样既能拿到自增主键,又能和后面的操作保持在同一个事务内。这个细节是我在写积分系统时踩过一次坑才明白的,当时用户已经建好了但积分表因为异常没写入,线上数据直接花了一上午才修复。
4. 把常用 API 串起来:一个真实的知识库小系统
4.1 三张表的设计:User、Article、Tag
为了把常用 API 串成一个完整故事,我拿知识库里的文章管理来演示。需求是:用户能发文章,文章可以打多个标签,标签可以重复用于多篇文章,典型的多对多关系。
from datetime import datetime, timezone from typing import Optional from sqlmodel import SQLModel, Field, Relationship class User(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) username: str = Field(index=True, unique=True, min_length=3, max_length=32) articles: list["Article"] = Relationship(back_populates="author") class ArticleTagLink(SQLModel, table=True): """多对多关联表""" article_id: Optional[int] = Field(default=None, foreign_key="article.id", primary_key=True) tag_id: Optional[int] = Field(default=None, foreign_key="tag.id", primary_key=True) class Article(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) title: str = Field(index=True, min_length=1, max_length=255) content: str author_id: Optional[int] = Field(default=None, foreign_key="user.id", index=True) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) author: Optional["User"] = Relationship(back_populates="articles") tags: list["Tag"] = Relationship(back_populates="articles", link_model=ArticleTagLink) class Tag(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) name: str = Field(index=True, unique=True) articles: list["Article"] = Relationship(back_populates="tags", link_model=ArticleTagLink)中间关联表是ArticleTagLink,它本身也是一张 SQLModel 表,并且两个字段联合主键。Relationship里的link_model参数告诉 SQLModel:这两个模型之间的多对多关系通过哪张中间表维护。这个设计在 SQLModel 0.0.19 之后是推荐做法,比直接操作sa_relationship更贴近模型层。
4.2 写入链:从注册到发文章
现在串起业务流程。第一步创建用户,第二步创建文章并打标签:
def register_and_publish(session: Session, username: str, title: str, tag_names: list[str]): # 1. 创建用户,flush 拿到 id user = User(username=username, hashed_password="fake-hash") session.add(user) session.flush() # 2. 根据名字查找或创建标签 tags: list[Tag] = [] for name in tag_names: tag = session.exec(select(Tag).where(Tag.name == name)).first() if not tag: tag = Tag(name=name) session.add(tag) session.flush() tags.append(tag) # 3. 创建文章并建立关系 article = Article(title=title, content="内容", author_id=user.id, tags=tags) session.add(article) session.commit() session.refresh(article) return article给article.tags直接赋一个 Tag 对象列表,SQLModel 会通过ArticleTagLink自动维护关系表,不需要你手动写中间记录的插入代码。这个体验和裸写 SQLAlchemy 时对比明显,少一截容易出错的胶水逻辑。
4.3 联表查询:join 与关系加载的取舍
需要查"某个作者的所有文章"时,用 join 很顺手:
stmt = ( select(Article) .join(User) .where(User.username == "alice") .order_by(Article.created_at.desc()) ) articles = session.exec(stmt).all()这条 SQL 会做 inner join,筛选出属于 alice 的文章。但如果还要拿到每篇文章的标签列表,就会出现经典的 N+1 问题:先查文章,再逐篇查标签。SQLModel 的解法是加载策略:
from sqlmodel import select from sqlmodel.sql.functions import func # 或者直接 selectinload from sqlalchemy.orm import selectinload stmt = ( select(Article) .options(selectinload(Article.tags)) .where(Article.author_id == user_id) ) articles = session.exec(stmt).all()加了selectinload之后,SQLModel 会额外生成一条IN查询,一次性把所有相关标签取回来。你访问article.tags时不会再触发数据库请求,序列化给前端时也就不会因为懒加载请求 Session 已关闭而抛异常。
4.4 通过 Relationship 反向取数
用户界面常常要展示一篇文章的标签名,或者一个人的文章数。反向关系在模型上直接就能用:
user = session.exec(select(User).where(User.username == "alice")).first() for article in user.articles: print(article.title, [tag.name for tag in article.tags])这里user.articles走的是back_populates建立的双向关系。但要注意:如果没有显式selectinload加载,访问user.articles会触发懒加载。在 Session 打开状态下没问题,一旦 Session 关闭再去访问,就会报DetachedInstanceError之类的错误。所以序列化之前,把需要的数据都加载好,这是我在写文章列表接口时踩过最多的坑之一。
5. 与 FastAPI 深度绑定:从请求到数据库的最后一公里
5.1 为什么 SQLModel 和 FastAPI 是天作之合
SQLModel 本质是 Pydantic 模型的子类,FastAPI 的请求体验证、响应序列化完全认它。这意味着你可以直接把模型当作接口层的数据结构。一个最经典的组合是:建表模型(table=True)负责数据库,非表模型(不加table)负责入参,都写在同一个模块里,共享字段定义。
5.2 使用 Session 依赖注入
FastAPI 里最重要的习惯是把 Session 做成依赖。每个请求打开自己的 Session,请求结束自动关闭,互不污染:
from fastapi import FastAPI, Depends, HTTPException from sqlmodel import Session, select app = FastAPI() def get_session(): with Session(engine) as session: yield session @app.get("/users/{username}") def get_user(username: str, session: Session = Depends(get_session)): user = session.exec(select(User).where(User.username == username)).first() if not user: raise HTTPException(status_code=404, detail="User not found") return user新版 FastAPI 推荐用Annotated风格:
from typing import Annotated DbSession = Annotated[Session, Depends(get_session)] @app.get("/users/{username}") def get_user(username: str, session: DbSession): ...两种写法效果一样。使用依赖注入后,你不用在每个函数里写with Session(engine) as session了,函数的职责更加单一。
5.3 请求体直接用非表模型接收
创建用户接口可以这么写。UserCreate不加table=True,它只是请求体结构:
class UserCreate(SQLModel): username: str = Field(min_length=3, max_length=32) password: str = Field(min_length=6) @app.post("/users", response_model=User) def create_user(payload: UserCreate, session: DbSession): exists = session.exec(select(User).where(User.username == payload.username)).first() if exists: raise HTTPException(status_code=400, detail="Username already exists") user = User(username=payload.username, hashed_password=payload.password) session.add(user) session.commit() session.refresh(user) return userresponse_model=User负责把返回对象转成 JSON 时会经过 Pydantic 序列化,所以数据库里多出来的hashed_password字段也会被返回,这在安全上要注意。更合适的做法是定义只对外暴露安全字段的响应模型,例如UserPublic,并在response_model里使用它。这是很多人初学时会忽略的点。
5.4 分页、过滤和排序的组合
列表接口几乎都会遇到分页。SQLModel 的select可以直接配合offset和limit:
@app.get("/articles") def list_articles( session: DbSession, tag_name: str | None = None, offset: int = 0, limit: int = 20, ): stmt = select(Article) if tag_name: stmt = stmt.join(Article.tags).where(Tag.name == tag_name) stmt = stmt.order_by(Article.created_at.desc()).offset(offset).limit(limit) return session.exec(stmt).all()这个接口可以在前端下拉加载时反复调用,也可以通过tag_name过滤指定标签下的文章。join(Article.tags)直接使用关系名做 join,不需要自己写关联条件。
5.5 外部 API 与数据库层的分工
回到最开始提到的场景:这个知识库后端还要调用大模型做向量化和对话。外部 API 的调用和数据库操作是两条独立的链路。比如你在调 DeepSeek、OpenRouter 或其他大模型 API 时碰到了401 unauthorized: incorrect api key provided这类报错,要先检查的是请求头里的 Authorization 和 Key 本身,而不是怀疑 Session 写库有问题。我见过有同事把外部 API 的 Key 配在环境变量里,但 FastAPI 进程没有重新加载,结果接口层一直 401,查了半天数据库,方向完全跑偏。把"外部 API 调用层"和"数据持久化层"在代码组织上分开,调试时能省很多力气。
6. 迁移工具、序列化陷阱与几个高频报错排查
6.1 Alembic 接入:别再每次 create_all 了
create_all只负责建表,不负责字段变更。项目上线后,你给模型加了一列,create_all不会自动帮你加,于是运行时查询就会报错。所以中大型项目要接 Alembic。SQLModel 官方文档有专门说明,核心步骤是:
pip install alembic alembic init alembic然后在alembic/env.py里导入 SQLModel 的 metadata:
from sqlmodel import SQLModel import models # 确保所有模型都被导入 target_metadata = SQLModel.metadata之后生成迁移文件和升级:
alembic revision --autogenerate -m "add column to article" alembic upgrade head需要注意autogenerate只能识别到模型和数据库之间的差异。如果你的表里已经存在数据,新增非空字段时要提供默认值,否则迁移会失败。SQLite 对很多 ALTER 操作支持有限,所以如果项目还没上线,建议直接删库重建;上线后用 PostgreSQL 会更省心。
6.2 序列化时的关系懒加载崩溃
这是 FastAPI + SQLModel 最常遇到的报错之一:接口返回时访问了未加载的 Relationship 字段,但 Session 已经关闭。比如:
@app.get("/articles/{id}") def get_article(id: int, session: DbSession): article = session.exec(select(Article).where(Article.id == id)).first() return article # 如果返回时尝试序列化 article.tags,就可能崩解决办法是在查询时显式加载:
stmt = ( select(Article) .options(selectinload(Article.tags)) .where(Article.id == id) ) article = session.exec(stmt).first()如果模型里定义了Relationship字段但接口层不想返回,也可以配置response_model为不带该字段的模型,Pydantic 不会去碰未声明字段,自然就不会触发懒加载。
6.3 字段命名:驼峰、下划线与数据库列的对应
Pydantic 默认字段名就是 Python 属性名。如果你前后端约定的是驼峰命名(createdAt),而数据库列是下划线(created_at),序列化之前要配置别名。一个简单方案是在响应模型里显式声明字段:
class ArticleOut(SQLModel): id: int title: str created_at: datetime = Field(alias="createdAt") model_config = {"populate_by_name": True}FastAPI 返回时的字段名就会变成createdAt,而 Python 内部还是用created_at访问。不要试图修改表模型本身的字段名,否则数据库列名会跟着变,容易引入隐藏的列映射偏差。
6.4 事务超时与连接泄漏
FastAPI 的依赖注入里,Session 是每请求一个。如果代码里忘记在finally里关闭,连接池会被耗尽,出现类似TimeoutError的问题。使用with Session(engine) as session的上下文管理器能避免大部分泄漏,依赖注入里的yield也会在请求结束后自动清理。还有个隐藏问题:如果session.exec抛了异常但你捕获后继续用同一个 Session,事务状态可能已经损坏,最好的做法是关闭当前 Session 并重新获取。
6.5 SQLite 与 PostgreSQL 的差异坑
本地用 SQLite 开发,部署到 PostgreSQL 后我踩过两处:第一,SQLite 的DateTime不会存时区,PostgreSQL 会用带时区类型,从库里取出来的时间格式可能不一致;第二,SQLite 相对宽松,某些非法数据能写进去,PostgreSQL 因为约束更严格直接报错。所以开发环境和生产环境尽量保持一致,或者从一开始就选 PostgreSQL 镜像跑本地。
6.6 误区提醒:401 类型报错不是 ORM 的锅
最后说一个和数据库无关但容易混淆的问题。很多靠 SQLModel 做数据层的小项目都会去接大模型 API,比如 DeepSeek、OpenRouter、智谱等。调试时遇到unexpected status 401 unauthorized: incorrect api key provided,可以先直接 curl 一下接口地址,带上 Key 看是否正常。如果 curl 正常,问题多半在代码里 Key 没读到或平台限制了请求来源;如果 curl 也报 401,就是服务商那边拒绝了这个 Key——可能是余额不足、Key 被禁、或者组织被停用。这类排查和 SQLModel 没有关系,别把时间耗在数据层上面。
7. 我现在组织项目的基本套路
把上面这些经验落成一个可复用的模板,我现在新建一个 FastAPI + SQLModel 项目时,结构大致是这样:
app/ main.py # FastAPI 实例与路由注册 db.py # engine、Session 依赖、get_session models/ user.py # User、UserCreate、UserPublic article.py # Article、ArticleTagLink、Tag routers/ users.py articles.py services/ ai_client.py # 外部大模型 API 调用每个模型文件里,table=True的表模型和不带table的入参/出参模型放一起,字段复用最方便。db.py里只放engine和get_session,不掺业务逻辑。routers 只处理请求、调用 services、返回响应。services 里放业务规则和外部 API 调用。
这套组织方式让我在处理知识库工具时非常舒服:文档切块的数据模型写在 models 里,向量化调用写在 services 里,两者互不干扰。当外部 API 的 Key 出问题时,我不需要翻数据库代码;当数据模型字段需要调整时,我也不用担心影响 AI 服务调用。
如果你现在正打算在 FastAPI 项目里上一个轻量级 ORM,SQLModel 值得认真考虑。它的核心价值不是"比裸 SQLAlchemy 多哪些功能",而是帮你把模型和接口的定义合并起来,减少重复代码。从我这次改造的体验来看,这个收益在日常迭代中是非常实在的。
最后分享一个小技巧:把select语句经常调用的条件封装成函数,比如_article_with_tags()返回带selectinload的基础查询,后续所有接口都从它派生。这样既避免了到处复制加载策略,又能保证每个接口默认拿到完整数据,不会在不知不觉中写出 N+1 查询。代码短了,人也轻松不少。