並び順カラム(sort_order)とは、利用者が決めた表示順をデータベースに保存するための列です。 作成日時や名前では決まらない「手で並べ替えた順番」を、ORDER BY sort_order で再現するために使います。

持たせ方は1つではなく、1件を動かしたときに何行のUPDATEが必要かが方式によって大きく変わります。この記事では5つの方式について、同じ10件のデータで1件を動かすSQLをMySQLとPostgreSQLの両方で実行し、更新行数とエラー文をそのまま載せます。

確認環境:Ubuntu 24.04 / PostgreSQL 16.15 / MySQL 8.0.46(InnoDB)/ Python 3.11(psycopg2 2.9、PyMySQL)。確認日 2026-10-06。出力例のデータベース名は appdb に置き換えています。

この記事の結論

  • 件数が少なく(1リスト数十件まで)同時編集がまれなら、連番+範囲UPDATEがいちばん素直です。値が1,2,3…と読めて、制約も付けやすくなります。
  • ドラッグ並べ替えが頻繁なら、飛び番(1000刻みの整数)か文字列キー(fractional indexing)にすると、1回の移動が1行のUPDATEで済みます。
  • 飛び番は同じすき間に9回入れると詰まる(1000刻みの場合)ので、再採番SQLを用意しておきます。
  • ユニーク制約つきの連番は、入れ替えの途中で重複エラーになります。 PostgreSQLは遅延制約、MySQLは一時退避値か ORDER BY つきUPDATEで回避できます。
  • 同時に並べ替える可能性があるなら、親(リスト)の行を SELECT ... FOR UPDATE でロックしてから位置を計算します。

並び順カラム5方式の早見表

10件のリストで「8番目の項目を3番目へ動かす」操作を、両方のDBで実行した結果です。

方式 列の型の例 1件移動の更新行数(実行結果) 同じ場所に入れ続けられる回数 ユニーク制約 実装量 向く場面
連番 INT 6行(動く範囲の行数) 制限なし 付けられるが入れ替えに工夫が要る 小 件数が少ない、順位を数字で見せたい
飛び番 INT / BIGINT 1行 1000刻みで9回、その後は再採番 付けやすい 小〜中(再採番が必要) ドラッグ並べ替えが多い
小数 DOUBLE PRECISION 1行 1000と2000の間で53回 付けやすい 小〜中(再採番が必要) 飛び番より再採番の間隔を空けたい
文字列キー VARCHAR(バイト順の照合順序) 1行 回数の上限なし(キーが伸びる) 付けやすい 中(キー生成関数が必要) 並べ替えが非常に多い、複数端末で編集する
前後ポインタ prev_id INT NULL 3行(件数によらず一定) 制限なし 付けにくい 大(取得に再帰CTE) 順にたどる処理が中心で、一覧のSQL並べ替えが不要

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「並び順カラムの設計|sort_orderの更新SQLと方式の比較表」

準備:テーブルと実行用の共通コード

5方式とも、次の形のテーブルに A〜J の10件を入れて試します。飛び番・小数・文字列キーは sort_order の型と初期値だけが違います。

CREATE TABLE items (
  id         INT PRIMARY KEY,
  name       VARCHAR(20) NOT NULL,
  sort_order INT NOT NULL
);
-- 連番: 1,2,3,…,10 / 飛び番: 1000,2000,…,10000

両方のDBに同じSQLを流し、更新行数(ドライバーの rowcount)と並びを表示するための共通コードです。接続先は自分の環境に合わせて書き換えてください。

# db.py
import psycopg2, pymysql

def pg():
    c = psycopg2.connect(host="127.0.0.1", user="app", password="secret", dbname="appdb")
    c.autocommit = True
    return c

def my():
    return pymysql.connect(host="127.0.0.1", user="app", password="secret",
                           database="appdb", autocommit=True)

DBS = [("PostgreSQL", pg), ("MySQL", my)]

def run(cur, sql, args=None):
    try:
        cur.execute(sql, args)
        print(f"  更新行数: {cur.rowcount}")
    except Exception as e:
        print("  エラー:", " / ".join(m.strip() for m in str(e).strip().splitlines() if m.strip()))

def order(cur, table="items", col="sort_order"):
    cur.execute(f"SELECT name, {col} FROM {table} ORDER BY {col}")
    print("  並び:", " ".join(f"{n}({p})" for n, p in cur.fetchall()))

以降の「実行結果」は、このコードの run() と order() の出力です。

方式1:連番(1,2,3…)と範囲UPDATE

動かす項目と、その間にある行をまとめて1文で更新します。 上へ動かすときは間の行を +1、下へ動かすときは -1 します。

-- H(id=8) を 8 番目から 3 番目へ(上へ移動)
UPDATE items
SET sort_order = CASE WHEN id = 8 THEN 3 ELSE sort_order + 1 END
WHERE sort_order BETWEEN 3 AND 8;

-- C(id=3、いま 4 番目) を 9 番目へ(下へ移動)
UPDATE items
SET sort_order = CASE WHEN id = 3 THEN 9 ELSE sort_order - 1 END
WHERE sort_order BETWEEN 4 AND 9;

実行結果(PostgreSQL 16 と MySQL 8.0 で同じ):

[1] 連番: H を 8 番目から 3 番目へ
  更新行数: 6
  並び: A(1) B(2) H(3) C(4) D(5) E(6) F(7) G(8) I(9) J(10)
[1'] 連番: C(いま4番目) を 9 番目へ(下へ移動)
  更新行数: 6
  並び: A(1) B(2) H(3) D(4) E(5) F(6) G(7) I(8) C(9) J(10)

更新行数は「移動元と移動先の間の行数」です。10件なら最大10行ですが、1万件のリストの末尾から先頭へ動かすと1万行のUPDATEになります。件数が増えるほど1回の移動が重くなるのが連番の性質です。

方式2:飛び番(1000刻み)

あらかじめ間隔を空けておき、移動先の前後の値の中間を入れます。 更新は動かす1行だけです。

-- H(id=8) を B(2000) と C(3000) の間へ
UPDATE items SET sort_order = (2000 + 3000) / 2 WHERE id = 8;

実行結果(両DBで同じ):

[2] 飛び番(1000刻み): H を B と C の間へ
  更新行数: 1
  並び: A(1000) B(2000) H(2500) C(3000) D(4000) E(5000) F(6000) G(7000) I(9000) J(10000)

先頭へ動かすときは「先頭の値 − 1000」、末尾へは「末尾の値 + 1000」を入れます。

すき間が奇数のとき:MySQLの割り算は小数になる

前後の値の合計が奇数になると、DBによって結果が変わります。

===== PostgreSQL =====
[6] すき間が奇数のときの中間値 (2000 + 3001) / 2
  / の結果: 2500
===== MySQL =====
[6] すき間が奇数のときの中間値 (2000 + 3001) / 2
  / の結果: 2500.5000
  DIV の結果: 2500
  INT 列に入った値: 2501

PostgreSQLは整数どうしの / が切り捨て、MySQLは小数になりINT列へ入るときに四捨五入されます。MySQLでは DIV を使うと両方のDBで同じ値になります。

何回で詰まるか

同じすき間(1000と2000の間)に、いつも前側へ入れ続けた回数を再帰CTEで数えました。

-- PostgreSQL(MySQL は / を DIV に変える)
WITH RECURSIVE r(n, lo, hi) AS (
  SELECT 0, 1000, 2000
  UNION ALL
  SELECT n + 1, lo, (lo + hi) / 2 FROM r WHERE (lo + hi) / 2 > lo
)
SELECT max(n) FROM r;
[7] 同じすき間に入れ続けられる回数
  飛び番 1000 と 2000 の間: 9 回
  小数 1000 と 2000 の間: 53 回
  小数 1 と 2 の間: 52 回

1000刻みでは9回で前後の差が1になり、それ以上は入りません。差が1になったら、後述の再採番SQLを流します。

桁あふれ

INT の上限は 2,147,483,647 です。1000刻みで末尾に足し続けると、約214万件ぶんで上限に届きます。上限を超える値を入れたときの実行結果です。

PostgreSQL: エラー: integer out of range
MySQL:      エラー: (1264, "Out of range value for column 'sort_order' at row 1")

削除と追加を繰り返すリストでは値だけが増えていくので、余裕を見るなら BIGINT にしておくと再採番の頻度だけを考えればよくなります。

方式3:小数(DOUBLE PRECISION)

考え方は飛び番と同じで、型を浮動小数点にします。

CREATE TABLE items_f (
  id INT PRIMARY KEY, name VARCHAR(20) NOT NULL,
  sort_order DOUBLE PRECISION NOT NULL
);
-- H(id=8) を B(2.0) と C(3.0) の間へ
UPDATE items_f SET sort_order = (2.0 + 3.0) / 2 WHERE id = 8;
[3] 小数(DOUBLE PRECISION): H を B と C の間へ
  更新行数: 1
  並び: A(1.0) B(2.0) H(2.5) C(3.0) D(4.0) E(5.0) F(6.0) G(7.0) I(9.0) J(10.0)

上の計測のとおり、同じすき間に入れられるのは52〜53回です。飛び番の9回より余裕はありますが、上限があることは変わりません。53回を超えると中間値が前後どちらかと同じ値になり、並びが決まらなくなります。小数を選ぶ場合も、再採番の手順は用意しておきます。

方式4:文字列キー(fractional indexing)

数値の代わりに、辞書順で比べられる文字列を位置として持つ方式です。 2つのキーの間に入るキーは、文字を1つ足せばいつでも作れるため、すき間が詰まることがありません。

キーを作る関数の最小の実装です(62種類の文字を文字コード順に使います)。

# fkey.py
DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"  # 文字コード順

def key_between(a, b):
    """a < b となる2つのキーの間に入るキーを返す。a='' は先頭より前、b=None は末尾より後ろ。"""
    if b is not None and not a < b:
        raise ValueError(f"a < b が必要です: {a!r}, {b!r}")
    out, n = "", 0
    while True:
        da = DIGITS.index(a[n]) if n < len(a) else 0
        db = DIGITS.index(b[n]) if b is not None and n < len(b) else len(DIGITS)
        if da == db:                      # 同じ文字はそのまま写す
            out += DIGITS[da]
        elif db - da > 1:                 # 間に文字があれば中央の1文字で終わり
            return out + DIGITS[(da + db) // 2]
        else:                             # 隣り合う文字なら、a側に1桁足して探す
            out += DIGITS[da]
            b = None
        n += 1

テーブルは、大文字と小文字を区別してバイト順に並ぶ照合順序を列に指定します。

-- PostgreSQL
CREATE TABLE items_k (
  id INT PRIMARY KEY, name VARCHAR(20) NOT NULL,
  sort_order VARCHAR(64) COLLATE "C" NOT NULL
);
-- MySQL
CREATE TABLE items_k (
  id INT PRIMARY KEY, name VARCHAR(20) NOT NULL,
  sort_order VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL
);
from fkey import key_between
k, keys = "", []
for _ in "ABCDEFGHIJ":                    # 末尾に足しながら初期キーを作る
    k = key_between(k, None); keys.append(k)
# … INSERT のあと、H を B と C の間へ
newkey = key_between(keys[1], keys[2])
cur.execute("UPDATE items_k SET sort_order = %s WHERE id = 8", (newkey,))

実行結果(両DBで同じ):

[4] 文字列キー: H を B と C の間へ
  並び: A(V) B(k) C(s) D(w) E(y) F(z) G(zV) H(zk) I(zs) J(zw)
  key_between('k', 's') = 'o'
  更新行数: 1
  並び: A(V) B(k) H(o) C(s) D(w) E(y) F(z) G(zV) I(zs) J(zw)

照合順序を指定しないとどうなるか

同じ5つの値 a・B・Z・b・A を、照合順序を変えて並べた結果です。

PostgreSQL
  COLLATE "C": A B Z a b
  COLLATE "en-US-x-icu": a A b B Z
MySQL
  COLLATE utf8mb4_0900_ai_ci: A a b B Z
    k = 'a' に一致する行数: 2
  COLLATE utf8mb4_bin: A B Z a b
    k = 'a' に一致する行数: 1

MySQL 8.0 の既定の照合順序 utf8mb4_0900_ai_ci では 'a' と 'A' が同じ値として扱われ、キー生成関数が想定する順番と一致しません。文字列キーの列には、必ずバイト順の照合順序(PostgreSQLは "C"、MySQLは ascii_bin や utf8mb4_bin)を付けます。

キーはどれくらい伸びるか

上の最小実装と、npm の fractional-indexing 4.0.0(generateKeyBetween)で、同じ操作を1000回繰り返したときのキーの長さです。

操作 回数 最小実装(上のコード) fractional-indexing 4.0.0
末尾に追加し続ける 10 / 100 / 1000 2 / 17 / 167 文字 2 / 3 / 3 文字
同じすき間に入れ続ける 10 / 100 / 1000 3 / 18 / 168 文字 4 / 22 / 202 文字

最小実装で先頭に入れ続けた場合は 3 / 21 / 201 文字でした。同じ場所に入れ続けると、どちらも約5〜6回で1文字ずつ伸びます。 末尾への追加はライブラリのほうが短く保てました。追加が末尾に集中する画面ではライブラリを使うか、キーが長くなったリストだけ作り直す(全行に新しいキーを振り直す)処理を用意します。VARCHAR(64) のように長さを決める場合は、超えたときに作り直す前提で設計します。

方式5:前後ポインタ(prev_id)

「自分の1つ前の行のid」を持たせる方式です。値ではなくつながりで順番を表します。

CREATE TABLE items_l (
  id INT PRIMARY KEY, name VARCHAR(20) NOT NULL,
  prev_id INT NULL          -- 先頭の行は NULL
);

-- H(id=8) を B(id=2) と C(id=3) の間へ
UPDATE items_l SET prev_id = CASE id
    WHEN 9 THEN 7   -- H の次だった I は、H の前だった G の後ろへ
    WHEN 8 THEN 2   -- H は B の後ろへ
    WHEN 3 THEN 8   -- B の次だった C は H の後ろへ
  END
WHERE id IN (9, 8, 3);

並び順どおりに取り出すには、再帰CTEで先頭からたどります。

WITH RECURSIVE walk AS (
  SELECT id, name, 1 AS n FROM items_l WHERE prev_id IS NULL
  UNION ALL
  SELECT i.id, i.name, w.n + 1 FROM items_l i JOIN walk w ON i.prev_id = w.id
)
SELECT name FROM walk ORDER BY n;

実行結果(両DBで同じ):

[5] 前後ポインタ(prev_id): H を B と C の間へ
  更新行数: 3
  並び: A B H C D E F G I J

更新は件数によらず3行で済みますが、ORDER BY と LIMIT だけでは途中のページを取り出せず、「上位10件」も先頭からたどることになります。一覧を並べ替えて表示する用途より、順にたどる処理に向いた持ち方です。

ユニーク制約つき連番で重複エラーになる例と回避

連番に UNIQUE (list_id, sort_order) を付けると、同じ番号が2つできないことをDBが保証してくれます。一方で、入れ替えの途中で一時的に同じ番号ができるため、そのままのUPDATEはエラーになります。

CREATE TABLE items (
  id INT PRIMARY KEY, list_id INT NOT NULL, name VARCHAR(20) NOT NULL,
  sort_order INT NOT NULL,
  CONSTRAINT uq_items_sort UNIQUE (list_id, sort_order)
);
-- A(1) と B(2) を入れ替える
UPDATE items SET sort_order = CASE id WHEN 1 THEN 2 WHEN 2 THEN 1 END WHERE id IN (1, 2);
-- 2〜4 番目を 1 つ後ろへずらす(A〜D の 4 件のとき)
UPDATE items SET sort_order = sort_order + 1 WHERE sort_order BETWEEN 2 AND 4;

実行結果:

===== PostgreSQL =====
[U1] エラー: duplicate key value violates unique constraint "uq_items_sort" / DETAIL:  Key (list_id, sort_order)=(1, 2) already exists.
[U2] エラー: duplicate key value violates unique constraint "uq_items_sort" / DETAIL:  Key (list_id, sort_order)=(1, 3) already exists.
===== MySQL =====
[U1] エラー: (1062, "Duplicate entry '1-2' for key 'items.uq_items_sort'")
[U2] エラー: (1062, "Duplicate entry '1-3' for key 'items.uq_items_sort'")

文が終わった時点では重複していなくても、1行を書き換えるたびに検査されるためです。PostgreSQLの公式ドキュメントにも、遅延可能と宣言していない一意制約は文の終わりではなく即時に検査されると書かれています。

回避1:遅延制約(PostgreSQL)

制約に DEFERRABLE を付けると、検査のタイミングを後ろへずらせます。

CONSTRAINT uq_items_sort UNIQUE (list_id, sort_order) DEFERRABLE INITIALLY DEFERRED
[U4] 遅延制約 (DEFERRABLE INITIALLY DEFERRED)
  更新行数: 2
  並び: B(1) A(2) C(3) D(4) E(5)
  更新行数: 4
  並び: B(1) E(2) A(3) C(4) D(5)
[U4'] 遅延制約でも重複したままならコミット時にエラー
  更新行数: 1
  COMMIT のエラー: duplicate key value violates unique constraint "uq_items_sort" / DETAIL:  Key (list_id, sort_order)=(1, 1) already exists.
[U5] DEFERRABLE INITIALLY IMMEDIATE (文の終わりで検査)
  更新行数: 2
  並び: B(1) A(2) C(3) D(4) E(5)
宣言 検査のタイミング 1文での入れ替え
指定なし(NOT DEFERRABLE) 1行ごと エラーになる
DEFERRABLE INITIALLY IMMEDIATE 文の終わり できる
DEFERRABLE INITIALLY DEFERRED コミット時 できる(複数の文に分けてもよい)

1文で入れ替えるだけなら INITIALLY IMMEDIATE で足ります。なお、遅延制約は ON CONFLICT の対象に指定できません。

[U6] エラー: ON CONFLICT does not support deferrable unique constraints/exclusion constraints as arbiters

回避2:一時退避値(両方のDBで使える)

動かす行をいったん使われていない値(負の値)へ逃がしてから、目的の値を入れます。 2文になるので、1つのトランザクションにまとめます。

BEGIN;
UPDATE items SET sort_order = -sort_order WHERE id IN (1, 2);
UPDATE items SET sort_order = CASE id WHEN 1 THEN 2 WHEN 2 THEN 1 END WHERE id IN (1, 2);
COMMIT;
[U3] 一時退避値(負の値)を使って入れ替える(PostgreSQL・MySQL とも)
  更新行数: 2
  更新行数: 2
  並び: B(1) A(2) C(3) D(4) E(5)

列に CHECK (sort_order > 0) を付けている場合や符号なしの型では負の値が入らないので、退避先は「最大値より大きい値」にします。

回避3:ORDER BY つきUPDATE(MySQL)

MySQLのUPDATEは ORDER BY を書けるので、ずらす向きの反対側から順に更新すれば重複しません。

UPDATE items SET sort_order = sort_order + 1
WHERE sort_order BETWEEN 2 AND 4 ORDER BY sort_order DESC;
[U2'] MySQL: ORDER BY sort_order DESC を付けて後ろから +1
  更新行数: 3
  並び: A(1) B(3) C(4) D(5)

+1 するときは DESC、-1 するときは ASC です。

飛び番が詰まったときの再採番SQL

前後の差が1になったリストだけを、現在の並びのまま1000刻みに振り直します。

-- PostgreSQL
UPDATE items i SET sort_order = r.rn * 1000
FROM (SELECT id, ROW_NUMBER() OVER (ORDER BY sort_order, id) AS rn
      FROM items WHERE list_id = 1) r
WHERE i.id = r.id AND i.sort_order <> r.rn * 1000;

-- MySQL
UPDATE items i
JOIN (SELECT id, ROW_NUMBER() OVER (ORDER BY sort_order, id) AS rn
      FROM items WHERE list_id = 1) r ON i.id = r.id
SET i.sort_order = r.rn * 1000;

実行結果(リスト1は A=1000, B=1001, C=1002, D=2000, E=3000、リスト2は X=1000 の状態から):

  更新行数: 4
  並び: 1:A(1000) 1:B(2000) 1:C(3000) 1:D(4000) 1:E(5000) 2:X(1000)

リスト2の X は変わっていません。MySQLの文には「値が同じ行を除く」条件を書いていませんが、更新行数は4でした。MySQLは値が実際に変わった行だけを数えるためです。

ユニーク制約が付いていると、再採番も途中で重複します(B を 2000 にする時点で D がまだ 2000)。

[MySQL ユニーク制約つきで再採番]
  エラー: (1062, "Duplicate entry '1-2000' for key 'items.uq_items_sort'")
[PostgreSQL ユニーク制約(遅延なし)つきで再採番]
  エラー: duplicate key value violates unique constraint "uq_items_sort" / DETAIL:  Key (list_id, sort_order)=(1, 2000) already exists.

この場合は、いったん負の値で振り、次に符号を戻す2段階にします。

BEGIN;
UPDATE items i
JOIN (SELECT id, ROW_NUMBER() OVER (ORDER BY sort_order, id) AS rn
      FROM items WHERE list_id = 1) r ON i.id = r.id
SET i.sort_order = -r.rn * 1000;
UPDATE items SET sort_order = -sort_order WHERE list_id = 1 AND sort_order < 0;
COMMIT;
[MySQL 2段階: いったん負の値へ、次に正の値へ]
  更新行数: 5
  更新行数: 5
  並び: A(1000) B(2000) C(3000) D(4000) E(5000)

再採番は「移動のたび」ではなく、中間値が前後どちらかと同じになったときだけ流せば十分です。

同時に並べ替えたときのロックの取り方

「いまの最大値を読んで +1 した値で追加する」処理を、2つの接続からほぼ同時に流しました。

def add_last(conn, label, item_id, name, lock, log):
    """リストの末尾に1件追加する。"""
    c = conn(); cur = c.cursor()
    if label == "PostgreSQL": c.autocommit = False
    else: c.begin()
    t0 = time.time()
    try:
        if lock:
            cur.execute("SELECT id FROM lists WHERE id = 1 FOR UPDATE")   # 親の行をロックして順番待ちにする
        cur.execute("SELECT COALESCE(MAX(sort_order), 0) + 1 FROM items WHERE list_id = 1")
        new_pos = cur.fetchone()[0]
        time.sleep(0.5)                                                   # アプリ側の処理時間
        cur.execute("INSERT INTO items VALUES (%s, 1, %s, %s)", (item_id, name, new_pos))
        c.commit()
        log.append(f"  {name}: sort_order={new_pos} で追加 (所要 {time.time()-t0:.1f} 秒)")
    except Exception as e:
        c.rollback()
        log.append(f"  {name}: エラー " + " / ".join(str(e).strip().splitlines()))
    c.close()

実行結果(PostgreSQL。MySQLもエラー文が 1062 になる以外は同じ並び):

[ロックなし・制約なし]
  X: sort_order=4 で追加 (所要 0.5 秒)
  Y: sort_order=4 で追加 (所要 0.5 秒)
  並び: A(1) B(2) C(3) X(4) Y(4)
[ロックなし・ユニーク制約あり]
  X: sort_order=4 で追加 (所要 0.5 秒)
  Y: エラー duplicate key value violates unique constraint "uq_items_sort" / DETAIL:  Key (list_id, sort_order)=(1, 4) already exists.
  並び: A(1) B(2) C(3) X(4)
[親の行を FOR UPDATE]
  X: sort_order=4 で追加 (所要 0.5 秒)
  Y: sort_order=5 で追加 (所要 0.9 秒)
  並び: A(1) B(2) C(3) X(4) Y(5)
状態 起きたこと 対応
ロックなし・制約なし 同じ値が2行でき、表示順が決まらない 避けたい状態
ロックなし・ユニーク制約あり 後から来た側がエラーで止まる アプリ側でやり直す
親の行を FOR UPDATE 後から来た側が待ち、続きの番号になる 並べ替え処理の基本形

並べ替えの対象は複数行にまたがるので、子の行ではなく「リストそのもの」を表す親の行をロックすると、追加・移動・再採番のすべてを同じ入口で順番待ちにできます。飛び番や文字列キーでも、2人が同じすき間に入れると同じ中間値になるため、考え方は同じです。

親テーブルが無い場合は、名前つきのロックが使えます。どちらも取得できることを確認しました。

-- PostgreSQL:トランザクションの中で取得する
SELECT pg_advisory_xact_lock(1);
-- MySQL:名前と待ち時間(秒)。1 が返れば取得、RELEASE_LOCK で解放
SELECT GET_LOCK('list:1', 5);
SELECT RELEASE_LOCK('list:1');

同じ値ができても表示が揺れないよう、取得側は ORDER BY sort_order, id と第2キーにidを付けておくと安定します。

似た方法との違いと選び方

方法 何を保存するか 並び順カラムとの違い
並び順カラム(この記事) 行ごとの位置 ORDER BY と LIMIT がそのまま使える
IDの配列を親に持つ 親の1行に [3,1,2] のような配列 1行の更新で済むが、SQLだけで並べ替え・ページ分けがしにくい
表示順を別テーブルに分ける (分類, 項目, 位置) の行 同じ項目を分類ごとに違う順で見せられる。位置の持ち方は5方式から選ぶ
作成日時や名前で並べる 何も追加しない 手で並べ替える要件が無ければこれで足りる

件数と更新頻度から決める判断表です。

条件 選ぶ方式
1リストが数十件まで、並べ替えは管理者がたまに行う 連番+範囲UPDATE
順位(1位、2位…)をそのまま画面に出したい 連番
1リストが数百件以上、ドラッグ並べ替えが日常的 飛び番(BIGINT、1000刻み)+再採番SQL
同じ場所への挿入が続く、再採番の時間を取りにくい 文字列キー
複数端末で編集し、あとでまとめて反映する 文字列キー
同じ項目を、分類やページごとに違う順で並べたい 表示順を別テーブルに分け、その中で上のいずれか
一覧表示より、順にたどる処理が中心 前後ポインタ

確かめて分かったこと

  • 整数どうしの割り算はDBで結果が違いました。 (2000 + 3001) / 2 はPostgreSQLで 2500、MySQLでは 2500.5000 となり、INT列には 2501 が入りました。MySQLでは DIV を使います。
  • 1000刻みの飛び番は、同じすき間に9回で詰まりました。 小数(DOUBLE PRECISION)でも52〜53回が上限でした。
  • PostgreSQLの一意制約は1行ごとに検査され、sort_order + 1 のような単純なずらしでも重複エラーになりました。 DEFERRABLE INITIALLY IMMEDIATE にすると1文の入れ替えが通りました。
  • 遅延制約は ON CONFLICT の対象にできませんでした。 UPSERTと併用する列では一時退避値のほうが合います。
  • MySQL 8.0 の既定の照合順序では 'a' と 'A' が同じ値になりました。 文字列キーの列にはバイト順の照合順序が必要です。
  • MySQLの更新行数は「値が変わった行」だけでした。 再採番で5行が対象でも、値が同じだった1行は数に入りませんでした。

よくある質問

Q. 並び順カラムとは何ですか? A. 利用者が手で決めた表示順を保存する列です。sort_order、position、display_order などの名前が使われ、ORDER BY に指定して使います。

Q. 連番と飛び番の違いは何ですか? A. 1件を動かしたときの更新行数が違います。連番は間にある行すべて、飛び番は動かす1行だけを更新します。その代わり、飛び番はすき間が無くなったときの再採番が必要です。

Q. 並べ替えでユニーク制約エラーになるときはどうしますか? A. 検査のタイミングを遅らせるか、途中の値を重ならない所へ逃がします。PostgreSQLは DEFERRABLE、MySQLは ORDER BY つきUPDATE、どちらでも使えるのは負の値への一時退避です。

Q. 飛び番の間隔はいくつにすればよいですか? A. 2の何乗に近いかで「同じ場所に何回入れられるか」が決まります。1000刻みで9回でした。値の上限に余裕がある BIGINT なら、間隔を大きく取っても困りにくくなります。

Q. 新しく追加した行の並び順はどう決めますか? A. 末尾に置くなら「最大値+間隔」、先頭に置くなら「最小値−間隔」です。最大値を読んでから追加するまでの間に他の追加が入らないよう、親の行をロックしてから計算します。

まとめ

  • 並び順カラムは、1件移動の更新行数で方式を選ぶ。 連番は間の行すべて、飛び番・小数・文字列キーは1行、前後ポインタは3行。
  • 件数が少なく同時編集がまれなら連番+範囲UPDATE、ドラッグ並べ替えが頻繁なら飛び番か文字列キー。
  • 飛び番は1000刻みで9回、小数は52〜53回で詰まる。 再採番SQLを ROW_NUMBER() で用意しておく。
  • ユニーク制約つきの連番は入れ替え途中で重複エラーになる。 遅延制約・一時退避値・ORDER BY つきUPDATEで回避する。
  • 文字列キーの列はバイト順の照合順序にする。 既定のままでは大文字と小文字が同じ値になることがある。
  • 同時に並べ替えるなら親の行を FOR UPDATE でロックし、取得側は ORDER BY sort_order, id にする。

参考・出典

  • PostgreSQL 16 Documentation「SET CONSTRAINTS」 https://www.postgresql.org/docs/16/sql-set-constraints.html (一意制約の検査タイミング、遅延できる制約の種類。2026-10-06 確認)
  • npm「fractional-indexing」4.0.0(キーの長さの計測に使用。2026-10-06 に導入して実行)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「並び順カラムの設計|sort_orderの更新SQLと方式の比較表」

株式会社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で一緒に開発しながら、 成長を楽しみませんか?