- データベース
PostgreSQL RETURNINGの使い方|更新結果とIDを取得
RETURNINGとは、INSERT・UPDATE・DELETE・MERGEで変更した行の内容を、その文の結果としてそのまま受け取れるPostgreSQLの句です。 文の末尾に RETURNING id と書くだけで、採番されたIDや更新後の値がSELECTと同じ形で返ってきます。「INSERTしたあとにもう一度SELECTする」という2回の往復が1回で済みます。
この記事では、4つの文それぞれの実行結果、ON CONFLICTと組み合わせたときに行が返らないケース、WITH句で削除行を履歴テーブルへ移す書き方、PostgreSQL 18で加わった old / new による更新前後の比較、そしてPython(psycopg)・Java(JDBC)・Node.js(node-pg)での受け取り方までを、すべて実行した出力つきでまとめます。
確認環境:Ubuntu 24.04、PostgreSQL 18.4(psql 16.15 から接続)。バージョン差の確認に PostgreSQL 16.15、クライアントは psycopg 3.3.6(Python 3.11)、PostgreSQL JDBC Driver 42.7.2(Java 21)、node-pg 8.23.1(Node.js 22)、比較用に MySQL 8.0.46 と SQLite 3.45.1 を使いました。
RETURNINGの早見表
| やりたいこと | 書き方 | 返る内容 | 確認した版 |
|---|---|---|---|
| 採番されたIDを受け取る | INSERT ... RETURNING id |
挿入した行のid | 16・18 |
| 挿入した行をまるごと受け取る | INSERT ... RETURNING * |
DEFAULT・IDENTITY・トリガー反映後の全列 | 16・18 |
| 更新後の値を受け取る | UPDATE ... RETURNING 列 |
更新後の値(対象0件なら0行) | 16・18 |
| 削除した行を受け取る | DELETE ... RETURNING * |
削除前の値 | 16・18 |
| 重複時は何もしない | INSERT ... ON CONFLICT DO NOTHING RETURNING id |
挿入できた行だけ(重複した行は返らない) | 16・18 |
| 挿入か更新かを見分ける | ON CONFLICT DO UPDATE ... RETURNING (old.id IS NULL) |
挿入なら true | 18 |
| 削除行を別テーブルへ移す | WITH d AS (DELETE ... RETURNING *) INSERT ... SELECT * FROM d |
移した行 | 16・18 |
| 更新前と更新後を並べる | UPDATE ... RETURNING old.price, new.price |
更新前の値と更新後の値 | 18 |
| MERGEの結果を受け取る | MERGE ... RETURNING merge_action(), t.* |
行ごとの操作名(INSERT / UPDATE / DELETE)と値 | 18(機能は17から) |
| 式や別名を返す | RETURNING price * 1.1 AS tax_included |
計算した値 | 16・18 |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「PostgreSQL RETURNINGの使い方|更新結果とIDを取得」
RETURNINGの基本と準備するテーブル
RETURNINGの後ろには、SELECTの列リストと同じものが書けます。列名、*、式、AS による別名のどれも使えます。返ってくるのは「その文が実際に変更した行」だけです。
以降の例は、次のテーブルを使って上から順に実行しています。そのまま貼り付けて同じ結果を再現できます(updated_at の時刻だけ実行した時刻になります)。
CREATE TABLE items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code text NOT NULL UNIQUE,
name text NOT NULL,
price integer NOT NULL,
stock integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);
id は自動採番(IDENTITY)、stock と updated_at はDEFAULTで埋まる列です。アプリ側が値を知らない列ほど、RETURNINGで受け取る価値があります。
INSERTで採番されたIDを取得する
1行挿入してIDだけ受け取る
INSERT INTO items (code, name, price) VALUES ('A-001', 'ノート', 300) RETURNING id;
id
----
1
(1 row)
INSERT 0 1
結果はSELECTと同じ表の形で返り、その下にコマンドタグ(INSERT 0 1)が出ます。currval() や lastval() を別に呼ぶ必要はありません。
複数行を挿入して、行ごとのIDを受け取る
INSERT INTO items (code, name, price, stock) VALUES
('A-002', 'ボールペン', 120, 50),
('A-003', '付箋', 250, 20)
RETURNING id, code, stock, updated_at::date AS d;
id | code | stock | d
----+-------+-------+------------
2 | A-002 | 50 | 2026-10-03
3 | A-003 | 20 | 2026-10-03
(2 rows)
INSERT 0 2
複数行INSERTでは、挿入した行の数だけ結果が返ります。 code のような自然キーも一緒に返しておくと、アプリ側で「どの入力がどのidになったか」を取り違えずに対応づけられます。
RETURNING * でDEFAULT値も含めて全列を受け取る
INSERT INTO items (code, name, price) VALUES ('A-004', 'クリップ', 80) RETURNING *;
id | code | name | price | stock | updated_at
----+-------+----------+-------+-------+-------------------------------
4 | A-004 | クリップ | 80 | 0 | 2026-10-03 18:41:16.003205+09
(1 row)
指定しなかった stock(DEFAULT 0)と updated_at(DEFAULT now())も、埋まった値が返ります。
式と別名を返す
INSERT INTO items (code, name, price) VALUES ('A-005', 'のり', 150)
RETURNING id, name || '(' || code || ')' AS label, price * 1.1 AS tax_included;
id | label | tax_included
----+---------------+--------------
5 | のり(A-005) | 165.0
(1 row)
トリガーや生成列で決まった値も受け取れる
RETURNINGが返すのは、BEFOREトリガーが書き換えたあとの行です。生成列(GENERATED ... STORED)の値も含まれます。
CREATE TABLE notes (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
slug text,
title_len integer GENERATED ALWAYS AS (char_length(title)) STORED
);
CREATE FUNCTION notes_set_slug() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.slug := lower(replace(NEW.title, ' ', '-'));
RETURN NEW;
END $$;
CREATE TRIGGER trg_notes_slug BEFORE INSERT OR UPDATE ON notes
FOR EACH ROW EXECUTE FUNCTION notes_set_slug();
INSERT INTO notes (title) VALUES ('Hello RETURNING World') RETURNING id, slug, title_len;
id | slug | title_len
----+-----------------------+-----------
1 | hello-returning-world | 21
(1 row)
INSERT文では slug を指定していませんが、トリガーが入れた値がそのまま返っています。「データベース側で決まった値を知りたい」ときは、どの仕組みで決まった値でもRETURNINGで受け取れます。
UPDATEで更新後の値を受け取る
UPDATE items SET price = price + 20 WHERE price < 200 RETURNING id, code, price;
id | code | price
----+-------+-------
2 | A-002 | 140
4 | A-004 | 100
5 | A-005 | 170
(3 rows)
UPDATE 3
UPDATEのRETURNINGで列名だけを書くと、返るのは更新後の値です。 price = price + 20 のような相対的な更新でも、結果の値をSELECTし直さずに受け取れます。
対象が1件もないときは、エラーではなく0行が返ります。
UPDATE items SET price = 0 WHERE code = 'ZZZ' RETURNING id;
id
----
(0 rows)
UPDATE 0
「0行が返る=対象なし」 と判定できるので、存在チェックのSELECTを前に置かなくて済みます。
DELETEで削除した行を受け取る
DELETE FROM items WHERE code = 'A-005' RETURNING id, code, name;
id | code | name
----+-------+------
5 | A-005 | のり
(1 row)
DELETE 1
DELETEのRETURNINGは、削除される直前の値を返します。 削除した内容をログに残す、画面に「〇〇を削除しました」と出す、といった用途にそのまま使えます。
ON CONFLICTと組み合わせたときの挙動
RETURNINGでいちばんつまずきやすいのがここです。ON CONFLICT DO NOTHINGで挿入を見送った行は、RETURNINGに出てきません。
DO NOTHINGで重複したら0行が返る
code が重複する行を入れてみます。
INSERT INTO items (code, name, price) VALUES ('A-001', 'ノート', 300)
ON CONFLICT (code) DO NOTHING RETURNING id;
id
----
(0 rows)
INSERT 0 0
エラーにはならず、0行です。既存行のid(1)は返りません。複数行のうち一部だけが重複した場合は、挿入できた行だけが返ります。
INSERT INTO items (code, name, price) VALUES ('A-001', 'ノートB5', 320), ('A-006', '消しゴム', 90)
ON CONFLICT (code) DO NOTHING RETURNING id, code;
id | code
----+-------
8 | A-006
(1 row)
INSERT 0 1
idが6ではなく8になっている点にも注目してください。重複で挿入を見送った行でも、採番(シーケンス)は1つ進みます。 連番に飛びが出るのは仕様どおりの動きです。
既存行のIDも欲しいときの書き方(挿入してもしなくてもIDを得る)
「あれば既存のid、なければ挿入して新しいid」が欲しいときは、WITH句で包んで既存行のSELECTとつなげます。
WITH ins AS (
INSERT INTO items (code, name, price) VALUES ('A-002', 'ボールペン', 120)
ON CONFLICT (code) DO NOTHING RETURNING id
)
SELECT id, 'inserted' AS src FROM ins
UNION ALL
SELECT id, 'existing' FROM items WHERE code = 'A-002' AND NOT EXISTS (SELECT 1 FROM ins);
id | src
----+----------
2 | existing
(1 row)
既存行だったので existing 側からid=2が返りました。新規のときは inserted 側から返ります。
DO UPDATEなら必ず行が返る(挿入か更新かの見分け方)
重複時に更新する(UPSERT)場合は、挿入でも更新でも行が返ります。
INSERT INTO items (code, name, price) VALUES ('A-001', 'ノートB5', 320), ('A-007', '定規', 200)
ON CONFLICT (code) DO UPDATE SET name = EXCLUDED.name, price = EXCLUDED.price, updated_at = now()
RETURNING id, code, name, (xmax = 0) AS inserted;
id | code | name | inserted
----+-------+----------+----------
1 | A-001 | ノートB5 | f
10 | A-007 | 定規 | t
(2 rows)
INSERT 0 2
(xmax = 0) はシステム列を使って「新規挿入かどうか」を見分ける、以前から知られている書き方です。PostgreSQL 18では、後述の old を使って (old.id IS NULL) AS inserted と、意味がそのまま読める形で書けます。
| 書き方 | 重複しなかった行 | 重複した行 |
|---|---|---|
ON CONFLICT DO NOTHING RETURNING id |
新しいidが返る | 返らない |
ON CONFLICT DO UPDATE ... RETURNING id |
新しいidが返る | 既存行のid(更新後の値)が返る |
ON CONFLICT DO UPDATE ... WHERE 条件 RETURNING id |
新しいidが返る | WHEREに合って更新された行だけ返る |
3行目の DO UPDATE ... WHERE は、後半の「17以前で同じ文を実行したときのエラー」で作る p テーブル(その時点で A=110、B=200)で確かめました。「今より高い価格のときだけ更新する」という条件です。
INSERT INTO p (code, price) VALUES ('A', 50), ('B', 500), ('C', 300)
ON CONFLICT (code) DO UPDATE SET price = EXCLUDED.price WHERE p.price < EXCLUDED.price
RETURNING id, code, price;
id | code | price
----+------+-------
2 | B | 500
5 | C | 300
(2 rows)
Aは条件に合わず更新されなかったので、結果に出てきません。DO UPDATEでも、WHEREを付けると「返らない行」が生まれます。
WITH句で削除した行を履歴テーブルへ移す
RETURNINGの結果は、WITH句(CTE)を通して別の文の入力にできます。「削除して、その行を履歴テーブルへ入れる」が1文で、同じスナップショットのなかで完結します。
CREATE TABLE items_history (
id bigint, code text, name text, price integer, stock integer,
deleted_at timestamptz NOT NULL DEFAULT now()
);
WITH moved AS (
DELETE FROM items WHERE stock = 0 AND code <> 'A-001'
RETURNING id, code, name, price, stock
)
INSERT INTO items_history (id, code, name, price, stock)
SELECT id, code, name, price, stock FROM moved
RETURNING id, code;
id | code
----+-------
4 | A-004
8 | A-006
10 | A-007
(3 rows)
INSERT 0 3
在庫0の行(A-001は残す条件にしています)が items から消え、同じ3行が items_history に入りました。件数を確かめると、どちらも3行です。
SELECT count(*) FROM items; SELECT count(*) FROM items_history;
count
-------
3
(1 row)
count
-------
3
(1 row)
DELETEとINSERTを別々に実行すると、そのあいだに別のセッションが行を変える余地があります。1文にまとめると、消した行と移した行が必ず一致します。
RETURNINGの結果を集計する
RETURNINGの中には集約関数を直接書けません。
UPDATE items SET price = price RETURNING count(*);
ERROR: aggregate functions are not allowed in RETURNING
LINE 1: UPDATE items SET price = price RETURNING count(*);
^
件数や合計が欲しいときは、WITH句で包んでから外側で集計します。
WITH upd AS (
UPDATE items SET stock = stock + 10 RETURNING stock
)
SELECT count(*) AS rows, sum(stock) AS total_stock FROM upd;
rows | total_stock
------+-------------
3 | 100
(1 row)
並び順を指定したいときも同じ形で、外側のSELECTに ORDER BY を書きます。RETURNING自体には並び順の指定がないためです。
flowchart LR
A["DELETE ... RETURNING"] -->|削除した行| B["WITH moved"]
B --> C["INSERT INTO 履歴 SELECT ... FROM moved"]
B --> D["SELECT count / sum ... FROM moved"]
PostgreSQL 18のold・newで更新前後を比較する
PostgreSQL 18から、RETURNINGの中で old と new という名前で、変更前の行と変更後の行をそれぞれ参照できます(公式ドキュメント「6.4. Returning Data from Modified Rows」18版に記載)。17までは、UPDATEで返せるのは更新後の値だけでした。
UPDATEで更新前・更新後・差分を並べる
UPDATE items SET price = price * 2 WHERE code IN ('A-002','A-003')
RETURNING code, old.price AS old_price, new.price AS new_price,
new.price - old.price AS diff;
code | old_price | new_price | diff
-------+-----------+-----------+------
A-002 | 140 | 280 | 140
A-003 | 250 | 500 | 250
(2 rows)
UPDATE 2
new.price は単に price と書いたときと同じ値ですが、old.price と並べるなら明示したほうが読みやすくなります。変更履歴(監査ログ)用の「前の値」を、事前のSELECTなしで取れるのが大きな利点です。
old.* と new.* で全列を並べることもできます。
UPDATE items SET stock = stock WHERE code='A-001' RETURNING old.*, new.*;
結果は「更新前の全列」に続けて「更新後の全列」が横に並びます(列名は同じものが2回出るので、アプリで受け取るときは別名を付けるのがおすすめです)。
実際に値が変わった行だけを見分ける
行全体を比べる書き方もできます。
UPDATE items SET name = name,
price = CASE WHEN code='A-002' THEN price+5 ELSE price END
RETURNING code, old.price, new.price, old IS DISTINCT FROM new AS changed;
code | price | price | changed
-------+-------+-------+---------
A-002 | 280 | 285 | t
A-003 | 500 | 500 | f
A-001 | 351 | 351 | f
(3 rows)
UPDATEの対象にはなったものの値が同じだった行は changed = f になります。通知や再計算を「本当に変わった行だけ」に絞る判定に使えます。
UPSERTで挿入か更新かを見分ける
ON CONFLICT DO UPDATE では、重複して更新になった行だけ old に値が入ります。新規挿入の行は old がすべてNULLです。
INSERT INTO items (code, name, price) VALUES ('A-001', 'ノートA4', 350), ('A-008', 'はさみ', 400)
ON CONFLICT (code) DO UPDATE SET name = EXCLUDED.name, price = EXCLUDED.price
RETURNING code, old.price AS old_price, new.price AS new_price,
(old.id IS NULL) AS inserted;
code | old_price | new_price | inserted
-------+-----------+-----------+----------
A-001 | 320 | 350 | f
A-008 | | 400 | t
(2 rows)
DELETEではnewがNULLになる
DELETE FROM items WHERE code = 'A-008' RETURNING old.code, old.price, new.code AS new_code;
code | price | new_code
-------+-------+----------
A-008 | 400 |
(1 row)
| 文 | old の中身 | new の中身 | 接頭辞なしの列 |
|---|---|---|---|
| INSERT | NULL | 挿入した行 | new と同じ |
| INSERT ... ON CONFLICT DO UPDATE(更新になった行) | 更新前の行 | 更新後の行 | new と同じ |
| UPDATE | 更新前の行 | 更新後の行 | new と同じ |
| DELETE | 削除前の行 | NULL | old と同じ |
oldやnewという名前の列・テーブルがあるときは別名を付ける
テーブルに old や new という名前が既にある場合は、WITH (OLD AS ..., NEW AS ...) で別名を付けられます。
UPDATE items SET price = price + 1 WHERE code='A-001'
RETURNING WITH (OLD AS o, NEW AS n) o.price AS before, n.price AS after;
before | after
--------+-------
350 | 351
(1 row)
17以前で同じ文を実行したときのエラー
PostgreSQL 16.15 で old を使うと、次のエラーで止まります。
ERROR: missing FROM-clause entry for table "old"
LINE 1: update p set price=price+10 returning old.price, new.price;
^
このエラーが出たら、サーバーのバージョンを SELECT version(); で確かめてください。17以前で更新前の値が必要な場合は、更新前の値を持つ副問い合わせをFROMで結合して返す書き方が使えます。次のSQLは16.15と18.4の両方で同じ結果になりました。
CREATE TABLE p (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, code text UNIQUE, price integer);
INSERT INTO p (code, price) VALUES ('A', 100), ('B', 200);
UPDATE p SET price = p.price + 10
FROM (SELECT id, price FROM p WHERE code = 'A' FOR UPDATE) b
WHERE p.id = b.id
RETURNING p.code, b.price AS old_price, p.price AS new_price;
code | old_price | new_price
------+-----------+-----------
A | 100 | 110
(1 row)
MERGEでRETURNINGを使う
MERGEは「あれば更新、なければ挿入、条件によっては削除」を1文で書ける文です。MERGEのRETURNINGはPostgreSQL 17で加わりました(17.0のリリースノートに「Allow MERGE to use the RETURNING clause」と記載)。どの操作が行われたかは merge_action() 関数で受け取れます。
CREATE TABLE stock_in (code text PRIMARY KEY, name text, price integer, qty integer);
INSERT INTO stock_in VALUES
('A-002','ボールペン',250,30),('A-009','テープ',180,12),('A-003','付箋',500,0);
MERGE INTO items i USING stock_in s ON i.code = s.code
WHEN MATCHED AND s.qty = 0 THEN DELETE
WHEN MATCHED THEN UPDATE SET stock = i.stock + s.qty
WHEN NOT MATCHED THEN
INSERT (code, name, price, stock) VALUES (s.code, s.name, s.price, s.qty)
RETURNING merge_action(), i.id, i.code, old.stock AS old_stock, new.stock AS new_stock;
merge_action | id | code | old_stock | new_stock
--------------+----+-------+-----------+-----------
UPDATE | 2 | A-002 | 60 | 90
INSERT | 14 | A-009 | | 12
DELETE | 3 | A-003 | 30 |
(3 rows)
1文の中で更新・挿入・削除が1行ずつ行われ、行ごとに操作名が返っています。old.stock / new.stock は18の機能なので、17では merge_action(), i.* のように書きます。接頭辞なしの列は、INSERTとUPDATEでは新しい値、DELETEでは削除前の値になります(公式ドキュメントのMERGEの項に記載)。
PostgreSQL 16.15 でMERGEにRETURNINGを付けると、構文エラーになります。
ERROR: syntax error at or near "returning"
LINE 1: ...ed then insert(code,price) values(s.code,s.price) returning ...
^
| 機能 | 16.15 | 18.4 | 補足 |
|---|---|---|---|
| INSERT / UPDATE / DELETE の RETURNING | 使える | 使える | 14版の公式ドキュメントにも記載 |
| ON CONFLICT と RETURNING、WITH句での受け渡し | 使える | 使える | 同じSQLで同じ結果 |
| MERGE ... RETURNING、merge_action() | 構文エラー | 使える | 17.0で追加(リリースノート) |
| RETURNING old.* / new.*、WITH (OLD AS ..., NEW AS ...) | エラー | 使える | 18で追加 |
アプリケーションからRETURNINGの結果を受け取る
RETURNING付きの文は、どのドライバでも「SELECTと同じように結果セットを読む」のが基本です。次のテーブルを使います。
CREATE TABLE tasks (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL UNIQUE,
done boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
以下の3つのプログラムは、空のテーブルに対して Python → Node.js → Java の順に続けて実行した結果です。
Python(psycopg 3)
import psycopg
from psycopg.rows import dict_row
with psycopg.connect("host=127.0.0.1 port=8701 dbname=t270 user=postgres") as conn:
# 1行:fetchone() で受け取る
cur = conn.execute(
"INSERT INTO tasks (title) VALUES (%s) RETURNING id", ("資料を作る",))
new_id = cur.fetchone()[0]
print("new_id =", new_id)
# 複数行:fetchall()。dict_row で列名つきにする
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(
"UPDATE tasks SET done = true WHERE id = %s RETURNING id, title, done",
(new_id,))
print(cur.fetchall(), "rowcount =", cur.rowcount)
# ON CONFLICT DO NOTHING:行が返らないと fetchone() は None
cur = conn.execute(
"INSERT INTO tasks (title) VALUES (%s) ON CONFLICT (title) DO NOTHING RETURNING id",
("資料を作る",))
print("conflict ->", cur.fetchone())
# executemany は returning=True を付けて nextset() でたどる
with conn.cursor() as cur:
cur.executemany(
"INSERT INTO tasks (title) VALUES (%s) RETURNING id",
[("会議室を予約",), ("備品を発注",)], returning=True)
ids = []
while True:
ids.append(cur.fetchone()[0])
if not cur.nextset():
break
print("ids =", ids)
new_id = 1
[{'id': 1, 'title': '資料を作る', 'done': True}] rowcount = 1
conflict -> None
ids = [3, 4]
重複時は fetchone() が None を返すので、cur.fetchone()[0] と続けて書くと TypeError: 'NoneType' object is not subscriptable になります。None かどうかを先に確かめる形にしておくと安心です。idが2を飛ばして3から始まっているのは、前述のとおり重複時にも採番が進むためです。
Node.js(node-pg)
import pg from "pg";
const client = new pg.Client({
host: "127.0.0.1", port: 8701, database: "t270", user: "postgres",
});
await client.connect();
// 1行:rows[0] で受け取る
const ins = await client.query(
"INSERT INTO tasks (title) VALUES ($1) RETURNING id, created_at",
["議事録を送る"]
);
console.log("rowCount =", ins.rowCount, "id =", ins.rows[0].id, typeof ins.rows[0].id);
// 複数行:rows は配列
const upd = await client.query(
"UPDATE tasks SET done = true WHERE done = false RETURNING id, title"
);
console.log(upd.rows);
// ON CONFLICT DO NOTHING:rows は空配列
const dup = await client.query(
"INSERT INTO tasks (title) VALUES ($1) ON CONFLICT (title) DO NOTHING RETURNING id",
["議事録を送る"]
);
console.log("rowCount =", dup.rowCount, "rows =", dup.rows, "id =", dup.rows[0]?.id);
await client.end();
rowCount = 1 id = 5 string
[
{ id: '3', title: '会議室を予約' },
{ id: '4', title: '備品を発注' },
{ id: '5', title: '議事録を送る' }
]
rowCount = 0 rows = [] id = undefined
node-pgでは bigint 型の列が文字列('5')で返ります。 JavaScriptの数値で扱える範囲を超える値を正しく運ぶための動きです。数値として使うなら Number() や BigInt() で変換するか、列を integer 型にします。重複時は rows が空配列になるので、rows[0]?.id のように書くと undefined で受けられます。
Java(JDBC)
JDBCでは2通りの受け取り方があります。SQLに RETURNING を書いて executeQuery() で読む方法と、JDBC標準の getGeneratedKeys() を使う方法です。
import java.sql.*;
public class RetJdbc {
public static void main(String[] args) throws Exception {
String url = "jdbc:postgresql://127.0.0.1:8701/t270";
try (Connection con = DriverManager.getConnection(url, "postgres", "")) {
// 方法1:RETURNING を書いて executeQuery() で受け取る
String sql = "INSERT INTO tasks (title) VALUES (?) RETURNING id, created_at";
try (PreparedStatement ps = con.prepareStatement(sql)) {
ps.setString(1, "見積もりを確認");
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
System.out.println("RETURNING id = " + rs.getLong("id"));
}
}
}
// 方法2:JDBC 標準の getGeneratedKeys()(列名を指定)
String sql2 = "INSERT INTO tasks (title) VALUES (?)";
try (PreparedStatement ps = con.prepareStatement(sql2, new String[] {"id"})) {
ps.setString(1, "名刺を注文");
int n = ps.executeUpdate();
try (ResultSet rs = ps.getGeneratedKeys()) {
rs.next();
System.out.println("rows = " + n + ", getGeneratedKeys id = " + rs.getLong(1));
}
}
// UPDATE ... RETURNING の複数行
try (PreparedStatement ps = con.prepareStatement(
"UPDATE tasks SET done = false WHERE id <= ? RETURNING id, title")) {
ps.setLong(1, 2);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
System.out.println(rs.getLong(1) + " " + rs.getString(2));
}
}
}
// エラーになる例:RETURNING 付きの文を executeUpdate() で実行
try (PreparedStatement ps = con.prepareStatement(sql)) {
ps.setString(1, "封筒を補充");
ps.executeUpdate();
} catch (SQLException e) {
System.out.println("SQLException: " + e.getMessage());
}
}
}
}
RETURNING id = 7
rows = 1, getGeneratedKeys id = 8
1 資料を作る
SQLException: A result was returned when none was expected.
RETURNING付きの文を executeUpdate() で実行すると「A result was returned when none was expected.」という例外になります。 結果セットが返る文なので executeQuery()(または execute() と getResultSet())を使います。
もう1点、実行後のテーブルを見ると、例外になった「封筒を補充」の行も入っていました。
id | title | done
----+----------------+------
1 | 資料を作る | f
3 | 会議室を予約 | t
4 | 備品を発注 | t
5 | 議事録を送る | t
7 | 見積もりを確認 | f
8 | 名刺を注文 | f
9 | 封筒を補充 | f
(7 rows)
自動コミットのままだと、例外が出てもINSERT自体は確定しています。例外を見て同じ文をもう一度実行すると二重登録になるので、メソッドの選び方を直すのが正しい対処です。
| ドライバ | 1行の受け取り | 複数行の受け取り | 行が返らないとき |
|---|---|---|---|
| psycopg 3 | cur.fetchone() |
cur.fetchall() |
fetchone() が None |
| node-pg | result.rows[0] |
result.rows |
rows が空配列、rowCount が 0 |
| JDBC | executeQuery() → rs.next() |
while (rs.next()) |
rs.next() が false |
MySQL・SQLite・SQL Serverとの対応表
ほかのデータベースから移ってきた方向けに、同じ目的の書き方を並べます。
| データベース | 変更行を受け取る書き方 | 複数行INSERTのID | 更新前の値 |
|---|---|---|---|
| PostgreSQL | RETURNING 列(INSERT / UPDATE / DELETE / MERGE) |
行ごとに返る | 18以降は old.列 |
| SQLite | RETURNING 列(3.35.0以降。INSERT / UPDATE / DELETE) |
行ごとに返る | DELETEは削除前、それ以外は変更後 |
| MySQL 8.0 | SELECT LAST_INSERT_ID(); を別に実行 |
最初の1行のIDだけ | 事前にSELECTする |
| SQL Server | OUTPUT INSERTED.列, DELETED.列(INSERT / UPDATE / DELETE / MERGE) |
行ごとに返る | DELETED.列 |
MySQL:LAST_INSERT_ID()
MySQL 8.0.46 で実行した結果です。
CREATE TABLE tasks (id bigint AUTO_INCREMENT PRIMARY KEY, title varchar(50) NOT NULL);
INSERT INTO tasks (title) VALUES ('資料を作る');
SELECT LAST_INSERT_ID();
INSERT INTO tasks (title) VALUES ('会議室を予約'), ('備品を発注');
SELECT LAST_INSERT_ID(), ROW_COUNT();
+------------------+
| last_insert_id() |
+------------------+
| 1 |
+------------------+
+------------------+-------------+
| last_insert_id() | row_count() |
+------------------+-------------+
| 2 | 2 |
+------------------+-------------+
2行挿入(id=2と3)したあとの LAST_INSERT_ID() は2です。MySQLのリファレンスマニュアルにも、複数行INSERTでは最初に挿入した行の値だけを返すこと、値は接続ごとに保持されることが書かれています。PostgreSQLのRETURNINGは行ごとに返るので、移行するときはこの違いを押さえておくと安心です。
SQLite:PostgreSQLとほぼ同じ書き方
SQLite 3.45.1(Pythonの sqlite3 モジュール)での実行結果です。
import sqlite3
c = sqlite3.connect(":memory:")
c.execute("CREATE TABLE tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0)")
cur = c.execute("INSERT INTO tasks (title) VALUES ('資料を作る') RETURNING id, title, done")
print(cur.fetchall())
cur = c.execute("INSERT INTO tasks (title) VALUES ('備品を発注')")
print("lastrowid =", cur.lastrowid)
print(c.execute("UPDATE tasks SET done = 1 RETURNING id, done").fetchall())
print(c.execute("DELETE FROM tasks WHERE id = 1 RETURNING *").fetchall())
[(1, '資料を作る', 0)]
lastrowid = 2
[(1, 1), (2, 1)]
[(1, '資料を作る', 1)]
SQLiteの公式ドキュメントによると、RETURNINGは3.35.0(2021-03-12)で加わり、PostgreSQLをもとにした構文です。出力の並び順は決まっておらず、サブクエリの中では使えない(アプリケーションへ返す用途のみ)とされています。PostgreSQLのようにWITH句で別の文へ渡す使い方はPostgreSQLならではの強みです。
SQL Server:OUTPUT句
SQL ServerではRETURNINGの代わりに OUTPUT 句を使います。Microsoft Learnの「OUTPUT clause (Transact-SQL)」によると、INSERT・UPDATE・DELETE・MERGEで使え、INSERTED.列(変更後)と DELETED.列(変更前)の接頭辞で値を指定します。OUTPUT ... INTO テーブル で結果を別テーブルへ入れることもできます。同ページに載っている形は次のとおりです。
UPDATE TOP (10) HumanResources.Employee
SET VacationHours = VacationHours * 1.25,
ModifiedDate = GETDATE()
OUTPUT INSERTED.BusinessEntityID,
DELETED.VacationHours,
INSERTED.VacationHours,
INSERTED.ModifiedDate
INTO @MyTableVar;
句を書く位置が文の末尾ではなくSETのあと(INSERTではVALUESの前)になる点が、RETURNINGとの違いです。PostgreSQL 18の old / new は、この DELETED / INSERTED に相当します。
つまずきやすい点と確認ポイント
| 症状 | 原因 | 対処 |
|---|---|---|
| ON CONFLICT DO NOTHINGでidが取れない | 重複した行はRETURNINGに出ない | WITH句+UNION ALLで既存行をSELECT、またはDO UPDATEにする |
| idが連番にならず飛ぶ | 重複で見送った行やロールバックしたINSERTでも採番は進む(ROLLBACK後の次のidが1つ飛ぶことを確認) | 仕様。連番を前提にしない設計にする |
aggregate functions are not allowed in RETURNING |
RETURNINGに集約関数は書けない | WITH句で包み、外側で count(*) などを使う |
missing FROM-clause entry for table "old" |
17以前で old / new を使った | 18へ上げる、または副問い合わせを結合して更新前の値を取る |
MERGEで syntax error at or near "returning" |
16以前でMERGEにRETURNINGを付けた | 17以降で使う |
JDBCで A result was returned when none was expected. |
executeUpdate() で実行した |
executeQuery() に変える |
| node-pgでidが文字列になる | bigint は文字列で返る | Number() / BigInt() で変換する |
| 結果の並びが期待と違う | RETURNINGには並び順の指定がない | WITH句で包み、外側のSELECTで ORDER BY |
チェックリストとしては、次の4点を確かめておくと安心です。
- 返ってくる行数が0のケースを考えたか(対象なし、重複で見送り)
- サーバーのバージョンで使える機能か(MERGEは17、old/newは18)
- ドライバで結果セットを読むメソッドを使っているか
RETURNING *で必要以上の列を返していないか(大きな列があるテーブルでは列を絞る)
よくある質問
Q. RETURNINGを使うと、INSERT後にSELECTするより速くなりますか? A. サーバーとの往復が1回減り、文も1つで済みます。加えて、UPDATEやDELETEでは「変更した行そのもの」が返るので、あとからSELECTしたときに別のセッションの変更が混ざる心配がありません。速度だけでなく、結果の確かさの面でも利点があります。
Q. ON CONFLICT DO NOTHINGで、既存行のidも一緒に返す方法はありますか?
A. RETURNING単体では返りません。この記事の「既存行のIDも欲しいときの書き方」のように、WITH句で包んで既存行のSELECTと UNION ALL でつなぐか、ON CONFLICT DO UPDATE にして必ず行が返る形にします。
Q. RETURNINGの結果を変数に入れたいときは?
A. PL/pgSQLの中では RETURNING id INTO 変数名 と書けます。次のDOブロックを実行すると NOTICE: new id = 6 と表示されました(p テーブルで実行)。SQLだけで別の文に渡したいときは、WITH句(CTE)を使います。
DO $$
DECLARE v_id bigint;
BEGIN
INSERT INTO p (code, price) VALUES ('D', 400) RETURNING id INTO v_id;
RAISE NOTICE 'new id = %', v_id;
END $$;
Q. RETURNING old/new は17以前では使えませんか?
A. 18からの機能です。16.15で実行すると missing FROM-clause entry for table "old" というエラーになることを確認しました。17以前では、更新前の値を持つ副問い合わせをFROMで結合して返す方法が使えます。
まとめ
- RETURNINGは、INSERT・UPDATE・DELETE・MERGEで変更した行をその文の結果として返す句。採番されたIDやDEFAULT値、トリガーが入れた値も1回で受け取れる
- UPDATEは更新後、DELETEは削除前の値が返る。対象がなければエラーではなく0行
- ON CONFLICT DO NOTHINGで見送った行は返らない。既存行のidも必要ならWITH句+UNION ALL、またはDO UPDATEを使う
- WITH句と組み合わせると、削除行を履歴テーブルへ移す処理や件数の集計が1文で書ける
- PostgreSQL 18では old / new で更新前後の値を並べて返せる。UPSERTの挿入・更新の判定は
old.id IS NULLで書ける - MERGEのRETURNINGは17以降。
merge_action()で行ごとの操作名がわかる - アプリ側は結果セットとして読む。psycopgは
fetchone()、node-pgはrows、JDBCはexecuteQuery()
参考・出典
- PostgreSQL 18 Documentation「6.4. Returning Data from Modified Rows」 https://www.postgresql.org/docs/18/dml-returning.html
- PostgreSQL 18 Documentation「MERGE」 https://www.postgresql.org/docs/18/sql-merge.html
- PostgreSQL 17 Release Notes https://www.postgresql.org/docs/release/17.0/
- MySQL 8.0 Reference Manual「Information Functions」 https://dev.mysql.com/doc/refman/8.0/en/information-functions.html
- SQLite「RETURNING」 https://www.sqlite.org/lang_returning.html
- Microsoft Learn「OUTPUT clause (Transact-SQL)」 https://learn.microsoft.com/en-us/sql/t-sql/queries/output-clause-transact-sql
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「PostgreSQL RETURNINGの使い方|更新結果とIDを取得」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。