PostgreSQL identity 列插入 NULL 报 violates not-null constraint
PostgreSQL identity 列显式插入 NULL 不会触发自动编号。通过省略列或 DEFAULT 修正 SQL,并为 JDBC 自动编号与指定编号分别绑定参数。
Java 程序往自增编号列传了 null,预期数据库生成编号,实际却收到:
ERROR: null value in column "id" violates not-null constraint
表结构里的这一列可能已经是 bigint GENERATED BY DEFAULT AS IDENTITY。显式传入 SQL NULL 不等于没有提供值。BY DEFAULT 接受调用方提供的值,而 identity 列本身要求非空,所以这个空值会被拒绝。
需要自动生成编号时,应从插入列清单里省略该列,或在 SQL 中写 DEFAULT。这适用于使用 identity 列的 PostgreSQL 插入操作;下面以普通表、单个 identity 列说明,具体语义按 PostgreSQL 18 核对,PostgreSQL 11 的插入语法也支持这两种写法。升级 JDBC 驱动不会把 SQL NULL 自动改成 DEFAULT。
核对列定义和真正发送的 INSERT
先在报错所用的数据库查询。示例表名是 public.tasks,编号列是 id,应替换为实际对象:
SELECT column_name, data_type, is_nullable,
is_identity, identity_generation
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'tasks'
ORDER BY ordinal_position;
如果 id 的 is_identity 为 YES、identity_generation 为 BY DEFAULT,本文的主要处理路径适用。若结果是 ALWAYS,显式值通常会先触发“不允许插入非 DEFAULT 值”的错误;若不是 identity 列,就要另查默认值和表定义。
接着查看应用生成的 SQL 与绑定参数,注意不要把敏感业务值输出到公开日志。下面两种形式都在向 id 提供空值:
INSERT INTO public.tasks (id, title)
VALUES (NULL, 'demo task');
以及 JDBC 的 VALUES (?, ?),第一个参数通过 setNull 或相应的空值绑定方法传入。检查对象中的 id == null 还不够:关键是 ORM 最后省略了这一列,还是把它写进 SQL 并绑定为 NULL。
自动编号时,省略 id 或写 DEFAULT
在新的隔离测试数据库中,下面的表定义可以作为示例;已有业务表不要为排错而重建:
CREATE TABLE public.tasks (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
title text NOT NULL
);
普通新增记录只传业务字段:
INSERT INTO public.tasks (title)
VALUES ('demo task')
RETURNING id;
如果固定列清单必须保留 id,则将 SQL 写成:
INSERT INTO public.tasks (id, title)
VALUES (DEFAULT, 'demo task')
RETURNING id;
这两个示例是二选一的写法,不要为同一条业务记录连续执行。RETURNING id 返回本次实际写入的编号,调用方应使用它关联后续操作,不要用查询全表最大编号的方式猜测自己插入的是哪一行。
DEFAULT 是 SQL 关键字。把字符串 "DEFAULT" 绑定给一个 ? 参数,不会让它变成关键字;setNull 也不会请求默认值。可以把 DEFAULT 直接写进预编译 SQL,只把业务字段保留为参数。
同时支持自动编号和指定编号的 JDBC 写法
如果业务确实有两种入口,一种生成新编号,另一种保留已审核的编号,可以分别使用两条固定 SQL。下面的方法放在项目的 DAO 类中,使用调用方提供的 Connection;事务的提交和回滚仍由调用方管理。它只适用于上面的 BY DEFAULT 表定义。
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
// Place this method inside your DAO class.
public static long insertTask(Connection connection,
Long suppliedId,
String title) throws SQLException {
String sql = suppliedId == null
? "INSERT INTO public.tasks (title) VALUES (?) RETURNING id"
: "INSERT INTO public.tasks (id, title) VALUES (?, ?) RETURNING id";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
int parameter = 1;
if (suppliedId != null) {
statement.setLong(parameter++, suppliedId);
}
statement.setString(parameter, title);
try (ResultSet result = statement.executeQuery()) {
if (!result.next()) {
throw new SQLException("Insert returned no id");
}
return result.getLong(1);
}
}
}
这里空编号分支不会把 id 放进插入列清单,因此触发自动生成;非空分支才绑定编号。所有业务值继续使用参数绑定,不需要把编号或标题拼进 SQL。指定编号仍受主键唯一性约束,重复编号应作为数据冲突处理。
若实际列定义为 GENERATED ALWAYS,自动编号分支仍可省略 id;指定编号分支则需要另外设计显式覆盖操作。不要仅为了让一条插入语句通过,就把全表改为 BY DEFAULT 或删除 identity 定义。
OVERRIDING SYSTEM VALUE 用于接受提供的 identity 值,不能把 NULL 变成自动编号。相反,OVERRIDING USER VALUE 会忽略所提供的 identity 值,但它也会忽略非空的指定编号,因此不适合用作“有编号就保留、没有就生成”的条件开关。
确认修复没有改变编号语义
先在隔离副本检查自动编号入口:不提供编号时插入成功,返回非空 id,并能用该编号查到对应业务字段。需要指定编号的入口,应另选不会冲突的测试编号,确认实际保存的就是该值。还应检查失败路径没有吞掉异常或把重复提交当成成功。
如果使用事务回滚测试,表里的插入可以撤销,但序列已经分配的值不会因此退回。编号出现间隔不代表插入修复失败,也不应为了填补间隔而重置序列。
同样,手工提供编号不能理解为自动序列已随之对齐。若入口用于导入旧数据,后续自动编号是否会撞上导入值,需要作为导入流程的一部分另外核对,不能拿这次解决空值错误来代替序列管理。
这次修改通常只需要调整插入语句和参数绑定。发布前保留旧 DAO 或 ORM 配置,确认所有新增入口的字段行为;回退代码不会自动删除已经提交的记录。遇到连接中断等结果不明的情况,先核对业务唯一标识对应的记录是否已存在,再决定是否重试,避免产生重复任务。


