unexpected null; try decoding as an Option
主线位置:查询主线的最后一步。
DataRow里这一列的长度是 -1(NULL),解码到一个不是Option的 Rust 类型时报这句。手写查询时是try_get在运行期报;query!时是编译期检查主线里的可空性推断给了「不可空」,生成的代码在运行期才撞上 NULL。解码的机制见类型的「Option:NULL 的唯一表达」,推断的机制见 describe 与可空性推断。
flowchart TB
val["列值是 NULL"] --> opt{"读成 Option 吗"}
opt -->|是| none["None"]
opt -->|否| who{"类型谁定的"}
who -->|"手写 query_as"| e1["写窄了"]
who -->|"query! 推断"| e2["推断说不可空"]
e1 --> err["unexpected null"]
e2 --> err
本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行,依赖 sqlx = "=0.9.0"。表在自己的 schema 里:
create table author (id int8 primary key, name text not null, bio text);
create table post (id int8 primary key, author_id int8 not null references author, title text not null);
create table writer (id int8 primary key, name text not null);
create table article (id int8 primary key, writer_id int8 not null references writer, title text not null);
insert into writer select g, 'w' || g from generate_series(1, 1000) g;
insert into article select g, g, 't' || g from generate_series(1, 10) g;
analyze writer;
analyze article;
-- item(id, name, price numeric, created_at timestamp, note text),note 前两行是 NULL
author / post 是两张没有数据、没 analyze 过的空表;writer / article 有数据、有统计信息,1000 个作者里只有前 10 个有文章。
现象
src/bin/unexpected_null.rs:
#[derive(Debug, sqlx::FromRow)]
struct Item {
id: i64,
note: String,
}
// ...
// runtime: query_as / FromRow / query_scalar
let r = sqlx::query_as::<_, (i64, String)>("select id, note from item order by id").fetch_all(&pool).await;
println!("tuple -> {}", r.unwrap_err());
let r = sqlx::query_as::<_, Item>("select id, note from item order by id").fetch_all(&pool).await;
println!("FromRow -> {}", r.unwrap_err());
let r = sqlx::query_scalar::<_, i64>("select max(id) from post").fetch_one(&pool).await;
println!("max(empty) -> {}", r.unwrap_err());
// query!: author/post are empty and never analyzed -> Hash Right Join
sqlx::query("insert into author values (1, 'ann', null)").execute(&pool).await?;
let r = sqlx::query!("select a.id, p.title from author a left join post p on p.author_id = a.id")
.fetch_one(&pool)
.await;
println!("right join -> {}", r.unwrap_err());
// query!: Limit on top of a Hash Left Join; writer 1000 has no article
let r = sqlx::query!("select w.id, a.title from writer w left join article a on a.writer_id = w.id order by w.id desc limit 1")
.fetch_one(&pool)
.await;
println!("limit -> {}", r.unwrap_err());
tuple -> error occurred while decoding column 1: unexpected null; try decoding as an `Option`
FromRow -> error occurred while decoding column "note": unexpected null; try decoding as an `Option`
max(empty) -> error occurred while decoding column 0: unexpected null; try decoding as an `Option`
right join -> error occurred while decoding column 1: unexpected null; try decoding as an `Option`
limit -> error occurred while decoding column 1: unexpected null; try decoding as an `Option`
- 前两行是最直接的情况:
note没有not null约束,类型却写成String。元组按下标取列,所以报column 1;#[derive(FromRow)]按名字取,报column "note"。 max(id):id是主键,可空表上的聚合返回一行 NULL。count(*)之外的聚合(max、min、sum、avg)都是这样。- 后两行用的是
query!,编译通过了,说明宏认为title不可能是 NULL,生成的字段类型是String。可两条都是LEFT JOIN,没有文章的作者那一行title就是 NULL。
出处
两种情况最后都走到同一个地方。Row::try_get 先检查类型兼容(值是 NULL 时跳过),再调 T::decode;String、i64 等 Postgres 类型的 decode 第一步都是 PgValueRef::as_bytes:
pub fn as_bytes(&self) -> Result<&'r [u8], BoxDynError> {
match &self.value {
Some(v) => Ok(v),
None => Err(UnexpectedNullError.into()),
}
}
UnexpectedNullError 的文字就是 unexpected null; try decoding as an `Option` ,外面再包一层 Error::ColumnDecode,加上 error occurred while decoding column {index}: 。Option<T> 的 Decode 先问 is_null(),是 NULL 就直接 None,不会走到 as_bytes。
query! 的不同在于类型是谁定的。宏在编译时对 SQL 调 describe,拿回每列的 nullable(i),在 output.rs 里决定包不包 Option:
let nullable = match nullability {
ColumnNullabilityOverride::NonNull => false,
ColumnNullabilityOverride::Nullable => true,
ColumnNullabilityOverride::None => describe.nullable(i).unwrap_or(true),
};
只有 Some(false) 会生成非 Option 的类型;生成的取值代码调的是 row.try_get_unchecked::<String, _>(...),运行期不再检查类型,NULL 照样交给 String 的 decode。这两条查询的推断结果可以在 cargo sqlx prepare 写出的 .sqlx/query-*.json 里看到(describe.nullable,下标对应列):
select a.id, p.title from author a left join post p on p.author_id = a.id [True, False]
select w.id, a.title from writer w left join article a on a.writer_id = w.id ord [False, False]
title 都是 False。推错的原因在 EXPLAIN 的计划树上,describe 与可空性推断的「实验:五种列」和动脑筋答案逐条跑过,这里只列结论:
| 查询 | 编译时的计划 | 推断 |
|---|---|---|
两张空表 LEFT JOIN | Hash Right Join,author 成了 Inner 那一侧 | id 误标可空(无害),title 不可空(错) |
有统计信息的表 LEFT JOIN ... LIMIT | 根节点是 Limit,连接在它下面 | visit_plan 不往下走,title 不可空(错) |
有统计信息的表 LEFT JOIN,没有 LIMIT / ORDER BY | 根节点是 Hash Left Join | title 可空(对) |
第一行是上游 #3202,到 0.9.0 仍开着。同一条 SQL 生成什么类型,取决于编译时那个库里的统计信息:开发库是空的、生产库有数据,就可能本地推成 String,换个库重新编译又变成 Option<String>。
问: query! 不是在编译期对着数据库检查过了吗,运行时怎么还会报 unexpected null?
答: 编译期检查的是类型(TEXT 对 String),可空性只是推断。Postgres 不在 RowDescription 里告诉客户端一列能不能是 NULL,sqlx 只能拿 pg_attribute.attnotnull 和 EXPLAIN 的计划树去猜,猜不出时给 Option,猜错时没有任何提示。生成的代码用 try_get_unchecked,运行期也不会再核对一次。宏的文档自己也说遇到推错的情况要用 ? 覆盖,并请用户报 issue。
修法
手写查询:类型写成 Option<T>,或者在 SQL 里保证不出 NULL(coalesce(note, ''))。max() 读成 Option<i64>:
let r = sqlx::query_scalar::<_, Option<i64>>("select max(id) from post").fetch_one(&pool).await;
println!("Option -> {:?}", r);
query!:在列名后面加 ?,强制可空。同一个程序的另外两段:
let r = sqlx::query!(r#"select a.id, p.title as "title?" from author a left join post p on p.author_id = a.id"#)
.fetch_one(&pool)
.await?;
println!("title? -> id {:?} title {:?}", r.id, r.title);
// ...
let r = sqlx::query!(r#"select w.id, a.title as "title?" from writer w left join article a on a.writer_id = w.id order by w.id desc limit 1"#)
.fetch_one(&pool)
.await?;
println!("title? -> id {:?} title {:?}", r.id, r.title);
Option -> Ok(None)
title? -> id Some(1) title None
title? -> id 1000 title None
第一条里 id 打印成 Some(1):Right Join 把 a.id 误标成可空,宏给了 Option<i64>。这一侧是多了一层 Option,不会出错;不想要可以写 a.id as "id!"。? 和 ! 只改可空性,不改类型;后缀语法和生成的代码见生成的代码。
.sqlx 里记的仍是 describe 的原始推断,as "title?" 那两条的 nullable 也是 [True, False] / [False, False]:覆盖发生在生成代码时,不改缓存文件。
怎么确认没中招
- 手写查询:对每个读成非
Option的列,看它在表上有没有not null;表达式列(聚合、case、外连接的另一侧)按可空处理。 query!:外连接(LEFT/RIGHT/FULL JOIN)补 NULL 那一侧的每一列,都显式写"col?",不依赖推断。带LIMIT、ORDER BY、GROUP BY的外连接尤其如此。- 用
cargo sqlx prepare生成.sqlx,在 JSON 里搜join,对照describe.nullable:外连接内侧的列是false就是推错了。 - 编译用的库要有和生产相近的数据和统计信息(至少跑过
analyze),否则空表会让规划器选出和生产不同的计划。
小结
- 报错来自
PgValueRef::as_bytes:值是 NULL、目标类型不是Option。column 1是下标,column "note"是列名。 - 手写查询的常见原因:没有
not null的列、外连接的另一侧、空集上的max/sum。修法是Option<T>或coalesce。 query!编译通过不代表可空性对:推断依赖编译时的EXPLAIN,空表上的Hash Right Join(#3202)和盖在连接上面的Limit/Sort都会把外连接内侧的列推成不可空。- 修法是
"col?";外连接内侧的列一律显式加上,不要等它在运行时报错。