返回数据(RETURNING)
写操作完成后还想知道"到底改了哪些行、改成了什么",过去只能再查一次——于是就有了"改完再 SELECT 却读到别人改的数据"这类竞态。RETURNING 让 INSERT / UPDATE / DELETE / MERGE 在修改的同时把行"交还"给你,一条语句、同一个快照、零额外往返。PostgreSQL 18 更进一步,支持用 OLD / NEW 同时取回修改前后的两份值。本节讲清它的取值语义、与 CTE 的组合套路,以及在生产代码里的定位:它是写操作的唯一可信回执。
前置知识
阅读本节前,建议先了解:批量导入导出(COPY)
基础概念
RETURNING 是写语句末尾的可选子句,内容与 SELECT 的输出列表规则完全一致:可以是列名、表达式、函数调用,也可以写 *。
它的核心价值有两个:
- 拿到数据库生成的值——自增主键、
DEFAULT now()、生成列,这些值写之前应用并不知道。 - 拿到"确实被改动"的行集合——
RETURNING只输出真正被插入/更新/删除的行,天然是"影响行数"的详细版。
| 语句 | 不写限定词时返回什么 | 可用的旧/新限定 |
|---|---|---|
INSERT | 插入后的新行 | old.*(通常为 NULL)/ new.*(等于默认) |
UPDATE | 更新后的新行 | old.*(修改前)/ new.*(等于默认) |
DELETE | 被删除的行 | old.*(等于默认)/ new.*(通常为 NULL) |
MERGE | 源行 + 目标行(可能大量重名) | old.* / new.* 均可用 |
几个关键术语:
- 回执(Receipt):把
RETURNING的结果当作"这次写入究竟发生了什么"的权威证据,而不是靠影响行数猜。 OLD/NEW:PostgreSQL 18 引入的行限定符,让你在同一行上同时看到修改前与修改后的值。new.price与直接写price等价,但语义更明确。- 单语句快照:
RETURNING看到的是本语句自己造成的结果,不受其他并发事务干扰,因此不存在"改完再查被别人插队"的问题。 - 与 CTE 的组合:
WITH ... AS (INSERT/UPDATE/DELETE ... RETURNING ...)可以把写操作的结果当作一张表继续用,实现"写 + 读 + 再写"的原子链路。 MERGE的列重名:MERGE的默认输出同时包含源行与目标行,因此RETURNING *常出现重复列名,建议用p.*/n.*明确限定。- 不支持的位置:
RETURNING只能出现在上述四种写语句里,COPY不支持,SELECT也不需要。
语法与用法
RETURNING 出现在每条写语句的末尾,语法骨架统一为:
RETURNING * | output_expression [ [ AS ] output_name ] [, ...]四种语句的最简形态:
-- INSERT:拿回数据库分配的主键与默认值
INSERT INTO orders (order_no, user_email, amount)
VALUES ('ORD-1001', 'alice@example.com', 199.00)
RETURNING id, order_no, status, created_at;
-- UPDATE:拿回更新后的行
UPDATE orders SET status = 'paid' WHERE order_no = 'ORD-1001'
RETURNING order_no, status, updated_at;
-- DELETE:拿回被删除的行(便于归档或审计)
DELETE FROM orders WHERE status = 'cancelled'
RETURNING id, order_no, amount;
-- MERGE:拿回每行实际执行的动作
MERGE INTO inventory t USING staged_inventory s ON t.sku = s.sku
WHEN MATCHED THEN UPDATE SET qty = s.qty
WHEN NOT MATCHED THEN INSERT (sku, qty) VALUES (s.sku, s.qty)
RETURNING merge_action() AS action, t.*;PostgreSQL 18 的 OLD / NEW 写法:
-- 同时看到改前与改后的价格,一次拿到差额
UPDATE products SET price = price * 1.10
WHERE price <= 99.99
RETURNING name,
old.price AS old_price,
new.price AS new_price,
new.price - old.price AS price_change; name | old_price | new_price | price_change
-----------+-----------+-----------+--------------
机械键盘 | 89.00 | 97.90 | 8.90
(1 row)
UPDATE 1MERGE 中 OLD / NEW 的语义与其他语句一致:
-- MERGE 里同样可以取回改前改后的值
MERGE INTO inventory t USING staged_inventory s ON t.sku = s.sku
WHEN MATCHED THEN UPDATE SET qty = s.qty
WHEN NOT MATCHED THEN INSERT (sku, name, qty, price)
VALUES (s.sku, s.name, s.qty, s.price)
RETURNING merge_action() AS action, t.sku, old.qty AS old_qty, new.qty AS new_qty;| 输出写法 | 含义 | 使用建议 |
|---|---|---|
RETURNING * | 目标表全部列 | 简单场景方便;MERGE 时应避免 |
RETURNING 表别名.* | 仅某张表的列 | MERGE / FROM 关联场景推荐 |
RETURNING 列 AS 别名 | 改列名 | 输出给应用或下游时强烈建议 |
RETURNING 表达式 | 计算列 | 如 amount * 0.1 AS tax |
RETURNING old.列 | 修改前的值(18+) | 审计、计算差额 |
RETURNING new.列 | 修改后的值(18+) | 与裸列名等价,但更清晰 |
RETURNING merge_action() | 实际动作(MERGE 专用) | 统计增删改分布 |
详细说明
INSERT 的 RETURNING:拿回数据库生成的值
INSERT 最常见的用途是取回自增主键——因为应用在写入前根本不知道那个值。
-- 拿回 IDENTITY 生成的主键与 DEFAULT 生成的默认值
INSERT INTO orders (order_no, user_email, amount)
VALUES ('ORD-1001', 'alice@example.com', 199.00)
RETURNING id, order_no, status, created_at; id | order_no | status | created_at
----+----------+---------+-------------------------------
1 | ORD-1001 | pending | 2026-09-24 16:40:12.318+08
(1 row)
INSERT 0 1多行插入时,RETURNING 按插入顺序返回每一行:
INSERT INTO orders (order_no, user_email, amount)
VALUES ('ORD-1002', 'bob@example.com', 880.00),
('ORD-1003', 'carol@example.com', 3200.00)
RETURNING id, order_no, amount; id | order_no | amount
----+----------+---------
2 | ORD-1002 | 880.00
3 | ORD-1003 | 3200.00
(2 rows)
INSERT 0 2INSERT ... SELECT 也支持 RETURNING,可以顺便做汇总:
-- 批量生成订单号并直接统计插入结果
INSERT INTO orders (order_no, user_email, amount)
SELECT 'ORD-BATCH-' || g, 'batch@example.com', g * 10
FROM generate_series(1, 5) AS g
RETURNING id, order_no, amount; id | order_no | amount
----+---------------+--------
4 | ORD-BATCH-1 | 10.00
5 | ORD-BATCH-2 | 20.00
6 | ORD-BATCH-3 | 30.00
7 | ORD-BATCH-4 | 40.00
8 | ORD-BATCH-5 | 50.00
(5 rows)
INSERT 0 5ON CONFLICT DO UPDATE 时,RETURNING 返回的是最终落库的那一行(无论是新插入还是被更新的):
INSERT INTO inventory (sku, name, qty, price)
VALUES ('SKU-1001', '机械键盘', 10, 399.00)
ON CONFLICT (sku) DO UPDATE
SET qty = inventory.qty + EXCLUDED.qty, updated_at = now()
RETURNING sku, qty, (xmax = 0) AS is_insert; sku | qty | is_insert
----------+-----+-----------
SKU-1001 | 130 | f
(1 row)
INSERT 0 1
-- is_insert = f 说明这一行走的是 UPDATE 分支INSERT ... ON CONFLICT 配合 OLD / NEW(PostgreSQL 18+)能精确看出"这次写入把值从多少改成了多少":
-- PostgreSQL 18+:冲突更新时同时取回旧值与新值
INSERT INTO inventory (sku, name, qty, price)
VALUES ('SKU-1001', '机械键盘', 10, 399.00)
ON CONFLICT (sku) DO UPDATE
SET qty = inventory.qty + EXCLUDED.qty
RETURNING sku, old.qty AS qty_before, new.qty AS qty_after; sku | qty_before | qty_after
----------+------------+-----------
SKU-1001 | 120 | 130
(1 row)UPDATE 的 RETURNING:确认"真的改到了"
UPDATE 默认返回新行。它的实战意义在于:用一行行的详细数据替代"UPDATE 1"这种无法追溯的提示。
-- 只更新真正需要改的行,并逐行确认
UPDATE inventory
SET qty = qty - 5, updated_at = now()
WHERE sku = 'SKU-1001' AND qty >= 5
RETURNING sku, qty AS qty_after, updated_at; sku | qty_after | updated_at
----------+-----------+-------------------------------
SKU-1001 | 125 | 2026-09-24 16:44:03.902+08
(1 row)
UPDATE 1UPDATE ... FROM 场景建议用表别名限定列,避免歧义:
UPDATE orders o
SET status = 'settled', updated_at = now()
FROM payments p
WHERE o.order_no = p.order_no AND p.status = 'success'
RETURNING o.order_no, o.status, p.pay_no, p.amount; order_no | status | pay_no | amount
----------+---------+--------+---------
ORD-1001 | settled | PAY-1 | 199.00
(1 row)
UPDATE 1RETURNING 只输出实际被更新的行,这让它成为验证 WHERE 是否写对的最佳工具:
-- 条件写错(如金额下限写成 100000),返回 0 行,立刻暴露问题
UPDATE orders SET status = 'vip' WHERE amount > 100000
RETURNING order_no, amount; order_no | amount
----------+--------
(0 rows)
UPDATE 0DELETE 的 RETURNING:删除的同时搬走数据
删除是不可逆的,RETURNING 是唯一能在"同一时刻"留下痕迹的方式。
-- 删除前先看一眼(用 SELECT 预演)
SELECT id, order_no, amount FROM orders WHERE status = 'cancelled';-- 删除并取回被删内容
DELETE FROM orders WHERE status = 'cancelled'
RETURNING id, order_no, amount, now() AS deleted_at; id | order_no | amount | deleted_at
----+----------+---------+-------------------------------
9 | ORD-2001 | 120.00 | 2026-09-24 16:47:55.117+08
10 | ORD-2002 | 80.00 | 2026-09-24 16:47:55.117+08
(2 rows)
DELETE 2被删除的行在 RETURNING 里可以自由计算,例如生成归档记录:
DELETE FROM orders WHERE created_at < now() - interval '365 days'
RETURNING order_no, user_email, amount, 'archived' AS archive_reason;CTE + RETURNING:一条语句完成"写 → 读 → 再写"
这是 RETURNING 最有威力的用法。写语句放在 WITH 里,其结果就是一张能被外层继续消费的表。
-- 删除待归档订单,并把它们写入归档表(一条语句,原子完成)
WITH moved AS (
DELETE FROM orders
WHERE status = 'expired'
RETURNING id, order_no, user_email, amount, status, created_at
)
INSERT INTO orders_archive (order_no, user_email, amount, status, created_at)
SELECT order_no, user_email, amount, status, created_at FROM moved;INSERT 0 3同一个 CTE 可以被引用多次,实现"搬移 + 统计":
WITH moved AS (
DELETE FROM orders WHERE status = 'expired'
RETURNING order_no, amount
), archived AS (
INSERT INTO orders_archive (order_no, amount, status)
SELECT order_no, amount, 'expired' FROM moved
RETURNING order_no
)
SELECT (SELECT count(*) FROM archived) AS archived_rows,
(SELECT sum(amount) FROM moved) AS archived_amount; archived_rows | archived_amount
---------------+-----------------
3 | 2480.00
(1 row)注意:CTE 中的写语句一定会执行,无论外层是否引用它。这与 SELECT 里"没被引用的 CTE 仍会执行"是同一套规则,因此不要在 CTE 里放"可能不该执行"的写操作。
-- 危险:即使外层不用 moved,DELETE 照样会执行
WITH moved AS (
DELETE FROM orders WHERE status = 'expired' RETURNING *
)
SELECT count(*) FROM orders;RETURNING 在应用层的定位
RETURNING 不只是"方便",它是消除竞态的正确姿势。对比两种写法:
-- 反模式:插入后再查一次,期间可能有并发写入,且多一次往返
INSERT INTO orders (order_no, user_email, amount) VALUES ('ORD-3001', 'x@example.com', 10);
SELECT id, created_at FROM orders WHERE order_no = 'ORD-3001';
-- 正确:一次拿回,且在写行锁的保护下
INSERT INTO orders (order_no, user_email, amount)
VALUES ('ORD-3001', 'x@example.com', 10)
RETURNING id, created_at;RETURNING 与"影响行数"的关系:
| 关注点 | 影响行数 | RETURNING |
|---|---|---|
| 是否成功 | 有 | 有(行数) |
| 改了哪一行 | 无 | 有 |
| 改成了什么 | 无 | 有 |
| 数据库生成的值 | 无 | 有 |
| 网络开销 | 极小 | 随返回列数与行数增长 |
因此有一条实用原则:写操作一律带 RETURNING,把影响行数断言升级为内容断言。
实战示例
下面用一个"下单 → 状态推进 → 归档"的完整流程演示四种语句的 RETURNING 用法。
-- 步骤 1:准备数据
DROP TABLE IF EXISTS orders_archive;
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS products;
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
price numeric(12,2) NOT NULL CHECK (price >= 0),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no text NOT NULL UNIQUE,
user_email text NOT NULL,
amount numeric(12,2) NOT NULL CHECK (amount >= 0),
status text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE orders_archive (LIKE orders INCLUDING ALL);
INSERT INTO products (name, price) VALUES
('机械键盘', 89.00),
('无线鼠标', 129.00),
('USB 集线器', 59.00);DROP TABLE
DROP TABLE
DROP TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 3-- 步骤 2:INSERT 拿回主键与默认值
INSERT INTO orders (order_no, user_email, amount)
VALUES ('ORD-4001', 'alice@example.com', 199.00),
('ORD-4002', 'bob@example.com', 880.00),
('ORD-4003', 'carol@example.com', 3200.00)
RETURNING id, order_no, status, created_at; id | order_no | status | created_at
----+----------+---------+-------------------------------
1 | ORD-4001 | pending | 2026-09-24 16:52:10.441+08
2 | ORD-4002 | pending | 2026-09-24 16:52:10.441+08
3 | ORD-4003 | pending | 2026-09-24 16:52:10.441+08
(3 rows)
INSERT 0 3-- 步骤 3:UPDATE 取回改前改后的值(PostgreSQL 18+ 的 OLD/NEW)
UPDATE products
SET price = round(price * 1.10, 2), updated_at = now()
WHERE price <= 99.99
RETURNING name,
old.price AS old_price,
new.price AS new_price,
new.price - old.price AS price_change; name | old_price | new_price | price_change
------------+-----------+-----------+--------------
机械键盘 | 89.00 | 97.90 | 8.90
USB 集线器 | 59.00 | 64.90 | 5.90
(2 rows)
UPDATE 2-- 步骤 4:带条件的 UPDATE —— 用 RETURNING 验证条件是否命中
UPDATE orders
SET status = 'paid', amount = amount - 20
WHERE order_no IN ('ORD-4001', 'ORD-4002') AND status = 'pending'
RETURNING order_no, status, amount; order_no | status | amount
----------+--------+---------
ORD-4001 | paid | 179.00
ORD-4002 | paid | 860.00
(2 rows)
UPDATE 2-- 步骤 5:UPDATE ... FROM + RETURNING,一次看到两表信息
UPDATE orders o
SET status = 'settled', amount = o.amount * 0.9
FROM products p
WHERE o.status = 'paid' AND p.name = '机械键盘'
RETURNING o.order_no, o.status, p.name AS matched_product, o.amount; order_no | status | matched_product | amount
----------+---------+-----------------+---------
ORD-4001 | settled | 机械键盘 | 161.10
ORD-4002 | settled | 机械键盘 | 774.00
(2 rows)
UPDATE 2-- 步骤 6:DELETE + RETURNING,删除的同时取回被删内容
DELETE FROM orders
WHERE status = 'pending'
RETURNING id, order_no, amount, now() AS deleted_at; id | order_no | amount | deleted_at
----+----------+---------+-------------------------------
3 | ORD-4003 | 3200.00 | 2026-09-24 16:55:02.883+08
(1 row)
DELETE 1-- 步骤 7:CTE + RETURNING 原子搬移(删除 + 归档一步完成)
WITH moved AS (
DELETE FROM orders
WHERE status = 'settled'
RETURNING id, order_no, user_email, amount, status, created_at
)
INSERT INTO orders_archive (order_no, user_email, amount, status, created_at)
SELECT order_no, user_email, amount, status, created_at FROM moved
RETURNING order_no, amount; order_no | amount
----------+---------
ORD-4001 | 161.10
ORD-4002 | 774.00
(2 rows)
INSERT 0 2-- 步骤 8:核对搬移结果 —— 业务表已空,归档表有数据
SELECT (SELECT count(*) FROM orders) AS live_orders,
(SELECT count(*) FROM orders_archive) AS archived_orders,
(SELECT sum(amount) FROM orders_archive) AS archived_amount; live_orders | archived_orders | archived_amount
-------------+-----------------+-----------------
0 | 2 | 935.10
(1 row)-- 步骤 9:CTE 中一次完成搬移与统计
WITH moved AS (
DELETE FROM orders_archive WHERE status = 'settled'
RETURNING order_no, amount
)
SELECT count(*) AS purged_rows, coalesce(sum(amount), 0) AS purged_amount FROM moved; purged_rows | purged_amount
-------------+---------------
2 | 935.10
(1 row)
DELETE 2-- 步骤 10:UPSERT + RETURNING,区分"新插入"与"被更新"
INSERT INTO orders (order_no, user_email, amount)
VALUES ('ORD-4001', 'alice@example.com', 199.00)
ON CONFLICT (order_no) DO UPDATE
SET amount = orders.amount + EXCLUDED.amount, status = 'reopened'
RETURNING order_no, status, amount, (xmax = 0) AS is_insert; order_no | status | amount | is_insert
----------+----------+---------+-----------
ORD-4001 | reopened | 360.10 | f
(1 row)
INSERT 0 1
-- is_insert = f 说明走了冲突更新分支;订单已被重新激活并累加金额上面的流程覆盖了 RETURNING 的主要形态:拿回生成值、OLD/NEW 对比、条件校验、多表关联返回、删除取回、CTE 原子搬移与统计。
注意事项
RETURNING只返回被实际修改的行。 条件没命中的行不会出现,因此"返回 0 行"是判断条件是否写对的最直接信号。MERGE的RETURNING *会包含源表与目标表两套列。 大量列重名会让结果难以使用,务必用表别名限定。
-- 不推荐:源与目标列混在一起
MERGE INTO inventory t USING staged_inventory s ON t.sku = s.sku
WHEN MATCHED THEN UPDATE SET qty = s.qty
RETURNING *;
-- 推荐:明确只要目标表的列
MERGE INTO inventory t USING staged_inventory s ON t.sku = s.sku
WHEN MATCHED THEN UPDATE SET qty = s.qty
RETURNING t.*;INSERT的old.*通常是NULL,DELETE的new.*通常是NULL。 这是语义使然,不要误以为写错了。COPY不支持RETURNING。 需要回执就得用INSERT ... RETURNING,或导入后再统一查询。CTE 里的写语句一定执行。 即使外层
SELECT从未引用它,DELETE/UPDATE也已经发生。
-- DELETE 会真实执行,哪怕外层只数了 orders
WITH moved AS (
DELETE FROM orders WHERE status = 'expired' RETURNING *
)
SELECT count(*) FROM orders;RETURNING的输出不参与行锁之外的一致性保证。 它反映的是本语句的效果,不是"数据库当前状态"——同一语句执行期间,其他事务可能已在别处发生了变更。CTE 版本是:
WITH ... AS (INSERT/UPDATE/DELETE ... RETURNING ...)才能作为数据源被引用;普通INSERT的结果无法直接再被查询。RETURNING会带来网络开销。 批量更新百万行时把全部列带回来会显著增加传输与内存占用,此时应只返回必要列,或干脆用影响行数。同一语句里
new.列与裸列名等价,但仅限目标表。 若列名在源表中也存在(MERGE/UPDATE ... FROM),不加限定会报歧义错误。RETURNING的顺序不保证与VALUES顺序一致。 虽然实践中通常按插入顺序返回,但不要把它当作合约,需要顺序就显式ORDER BY(RETURNING之后不能再写ORDER BY,得包一层 CTE 或子查询)。
-- 需要有序结果时,包一层外层查询
WITH ins AS (
INSERT INTO orders (order_no, user_email, amount)
VALUES ('ORD-5001', 'a@example.com', 10),
('ORD-5002', 'b@example.com', 20)
RETURNING id, order_no
)
SELECT * FROM ins ORDER BY order_no;最佳实践
- 写操作一律带
RETURNING。 它是"这次写入到底发生了什么"的唯一可信回执,能省掉一次查询并消除竞态。
INSERT INTO orders (order_no, user_email, amount)
VALUES ('ORD-6001', 'alice@example.com', 199.00)
RETURNING id, order_no, status, created_at;- 把返回内容当业务断言。 期望 1 行却返回 0 行,说明前置状态不符,应当走异常分支而不是静默继续。
UPDATE inventory SET qty = qty - 5 WHERE sku = 'SKU-1001' AND qty >= 5
RETURNING sku, qty;
-- 应用层检查:无返回行 → 抛"库存不足",触发回滚- 为输出列显式起别名。 应用代码依赖"字段名"而不是"第几列",别名让契约稳定。
UPDATE orders SET status = 'paid' WHERE order_no = 'ORD-6001'
RETURNING order_no, status AS new_status, updated_at AS changed_at;- 需要审计或差额,就用
OLD/NEW(PostgreSQL 18+)。 一次拿到改前改后,避免"再查一次旧值"的错误做法。
UPDATE products SET price = round(price * 1.10, 2)
WHERE price <= 99.99
RETURNING name, old.price AS old_price, new.price AS new_price,
new.price - old.price AS price_change;- 用 CTE +
RETURNING把"删除 + 归档"做成原子操作。 避免"删了但归档失败"或"归档了但删除失败"的中间态。
WITH moved AS (
DELETE FROM orders WHERE status = 'expired'
RETURNING order_no, user_email, amount, status, created_at
)
INSERT INTO orders_archive (order_no, user_email, amount, status, created_at)
SELECT order_no, user_email, amount, status, created_at FROM moved;UPDATE ... FROM/MERGE的RETURNING一律用表别名限定。 避免歧义报错,也让返回的列来源一目了然。
UPDATE orders o SET status = 'settled'
FROM payments p
WHERE o.order_no = p.order_no AND p.status = 'success'
RETURNING o.order_no, o.status, p.pay_no, p.amount;MERGE用merge_action()统计增删改分布。 同步任务最需要知道的正是这三类各有多少。
WITH changed AS (
MERGE INTO inventory t USING staged_inventory s ON t.sku = s.sku
WHEN MATCHED THEN UPDATE SET qty = s.qty
WHEN NOT MATCHED THEN INSERT (sku, qty) VALUES (s.sku, s.qty)
RETURNING merge_action() AS action, t.sku
)
SELECT action, count(*) AS cnt FROM changed GROUP BY action ORDER BY action;- 批量写入控制返回列的数量。 百万行级别只需返回主键或聚合值,不要把整行带回应用。
-- 大批量只需确认写入数量与主键范围
UPDATE orders SET status = 'expired' WHERE status = 'pending'
RETURNING id;- 需要稳定顺序时用外层查询包裹。
RETURNING子句本身不提供排序能力。
WITH ins AS (
INSERT INTO orders (order_no, user_email, amount)
SELECT 'ORD-' || g, 'batch@example.com', g * 10 FROM generate_series(1, 5) AS g
RETURNING id, order_no, amount
)
SELECT count(*) AS inserted, sum(amount) AS total, min(order_no) AS first_no FROM ins;下一节
继续学习:基础查询(SELECT)