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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?