ステータス設計とは、「この行はいまどの状態か」を表す列を、取りうる値・並び順・変わり方まで含めて決めることです。 2値で足りるならフラグ、並びに意味のない分類なら区分、順序と遷移があるならステータス、と使い分けると、列の名前と中身がそろいます。

この記事では、3つの違いと選び方を表で整理したうえで、ステータス列の持ち方4通りのDDLを PostgreSQL と MySQL で実際に流し、不正な値が拒否されるときのエラー文までそのまま載せます。状態遷移図の書き方と削除フラグの扱いは範囲から外し、列の定義に絞ります。

この記事の結論

条件 選ぶもの 列の例
2値で、今後も増えない フラグ is_gift_wrapped boolean
3つ以上の値があり、並びに意味がない 区分 delivery_type(宅配・店頭受取・置き配)
値に順序があり、決まった向きに変わる ステータス status(受付済み→入金済み→出荷済み)
いつ変わったかが1回分だけ必要 日時列 shipped_at timestamptz
変わった経過をすべて残したい 状態履歴テーブル order_status_history
  • 同時に成り立たない状態を複数のフラグで持つと、矛盾した行を登録できてしまいます。 そのような状態はステータス1列にまとめます。
  • ステータス列は、取りうる値をデータベース側の制約(CHECK・ENUM・外部キー)で決めておくと、つづりの誤った値が入りません。

確認環境:PostgreSQL 16.15、MySQL 8.0.46(どちらも Ubuntu 24.04 上、sql_mode は既定値)。確認日は 2026-10-05。掲載したSQLと実行結果はすべてこの環境で流したもので、データベース名は appdb に置き換えています。

フラグ・区分・ステータスの早見表

項目 フラグ 区分 ステータス
値の数 2つ(はい・いいえ) 3つ以上が多い 3つ以上が多い
値の順序 なし なし あり
値の変わり方 切り替わるだけ 原則変わらない(登録時に決まる) 決まった向きに進む
同時に複数成り立つか 列ごとに独立 1行に1つ 1行に1つ
典型的な型 boolean varchar+制約、またはコードマスタ参照 varchar+制約、ENUM、コードマスタ参照
列名の例 is_〜 has_〜 〜_type 〜_kind status 〜_status
例 ギフト包装あり 配送方法、会員種別 注文の進み具合、申請の承認状況
値が増えるときの作業 列の追加 値の追加 値の追加と、遷移の見直し

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

(記載例)出典:株式会社RJC「ステータス設計:フラグ・区分・ステータスの違いとテーブル定義例」

フラグ・区分・ステータスの違い

3つの違いは、値の数・順序・変わり方の3点で説明できます。

フラグは、ある性質が「ある・ない」の2値を表す列です。is_gift_wrapped(ギフト包装あり)のように、ほかの列と独立していて、3つ目の値が出てこないものに向いています。

区分は、行を種類分けするための値です。配送方法の「宅配・店頭受取・置き配」のように、どれが先でどれが後という順序がありません。登録したときに決まり、その後はあまり変わらないのが特徴です。

ステータスは、行がたどる段階を表す値です。「受付済み→入金済み→出荷済み」のように順序があり、進める向きが決まっています。同じ行の値が時間とともに書き換わっていく点が、区分との大きな違いです。

見分ける質問 はい いいえ
値は2つだけで、3つ目が出る見込みはないか フラグ 区分かステータス
値どうしに「前・後」があるか ステータス 区分
同じ行の値が時間とともに変わるか ステータス(またはフラグ) 区分
ほかの列と同時に成り立たない組み合わせがあるか 1列にまとめる 別々の列でよい

列名もこの区別に合わせます。多くの値が入る列に 〜_flag と名付けると、読む人は2値だと受け取ります。 値が3つ以上になった時点で、名前を 〜_type や status に改めると誤解が減ります。

どれを使うかの判断基準

こういう要件のとき 選ぶもの 理由
「メールマガジンを受け取るか」を持ちたい フラグ 2値で、ほかの状態と独立している
「公開中か下書きか」を持ちたいが、今後「予約公開」も増えそう ステータス 3つ目の値が見えている
「会員種別(一般・学生・法人)」を持ちたい 区分 順序がなく、登録時に決まる
「出荷済みか」と「いつ出荷したか」の両方がほしい 日時列 日時が入っていれば出荷済みと分かり、列が1つで済む
「誰がいつ承認し、いつ差し戻したか」を全部ほしい 状態履歴テーブル 同じ状態を2回以上通ることがある
「支払い済み」「出荷済み」「取消済み」を持ちたい ステータス 同時に成り立たない組み合わせがある

フラグを足し続けるとどうなるか:矛盾した行の再現

注文テーブルに「入金済み」「出荷済み」「取消済み」の3つのフラグを足していった場合を、PostgreSQL で再現します。

CREATE TABLE orders_flag (
  order_id     integer PRIMARY KEY,
  is_paid      boolean NOT NULL DEFAULT false,
  is_shipped   boolean NOT NULL DEFAULT false,
  is_cancelled boolean NOT NULL DEFAULT false
);
INSERT INTO orders_flag VALUES
  (1, false, false, false),
  (2, true,  false, false),
  (3, true,  true,  false),
  (4, false, true,  true),
  (5, true,  true,  true);
SELECT * FROM orders_flag ORDER BY order_id;
INSERT 0 5
 order_id | is_paid | is_shipped | is_cancelled 
----------+---------+------------+--------------
        1 | f       | f          | f
        2 | t       | f          | f
        3 | t       | t          | f
        4 | f       | t          | t
        5 | t       | t          | t
(5 rows)

4番は「入金していないのに出荷済みで、しかも取消済み」、5番は「出荷済みかつ取消済み」です。型も NOT NULL 制約も満たしているので、データベースはそのまま受け入れます。

フラグが3本あると、組み合わせは 2×2×2=8通りになります。このうち業務として意味を定義できるのは「受付済み・入金済み・出荷済み・取消」の4通りだけで、残りの4通りは説明のつかない行です。フラグを1本足すたびに、組み合わせは2倍になります。

集計にも影響します。「出荷済みの件数」を数えるとき、条件の書き方で結果が変わります。

SELECT count(*) AS shipped_rows FROM orders_flag WHERE is_shipped;
SELECT count(*) AS shipped_and_not_cancelled
  FROM orders_flag WHERE is_shipped AND NOT is_cancelled;
 shipped_rows 
--------------
            3

 shipped_and_not_cancelled 
---------------------------
                         1

同じ「出荷済み」でも 3件と1件に分かれました。フラグで状態を持つと、問い合わせを書く人が毎回すべてのフラグの組み合わせを思い出す必要があります。

CHECK制約で矛盾を止める場合

フラグのまま矛盾を止めるには、成り立たない組み合わせを CHECK 制約で1つずつ書きます。

ALTER TABLE orders_flag
  ADD CONSTRAINT orders_flag_ck CHECK (NOT (is_shipped AND is_cancelled));
ERROR:  check constraint "orders_flag_ck" of relation "orders_flag" is violated by some row

すでに矛盾した行があると、制約の追加そのものが止まります。該当する行を直してから追加すると通り、その後は登録時に拒否されます。

DELETE FROM orders_flag WHERE is_shipped AND is_cancelled;
ALTER TABLE orders_flag
  ADD CONSTRAINT orders_flag_ck CHECK (NOT (is_shipped AND is_cancelled));
INSERT INTO orders_flag VALUES (6, false, true, true);
DELETE 2
ALTER TABLE
ERROR:  new row for relation "orders_flag" violates check constraint "orders_flag_ck"
DETAIL:  Failing row contains (6, f, t, t).

ただし、この制約が止めたのは「出荷済みかつ取消済み」だけです。「入金していないのに出荷済み」を止めるには、もう1本の制約が要ります。フラグが増えるほど制約の本数も増えるので、同時に成り立たない状態は、はじめから1列で持つほうが定義が短くなります。

フラグを足し続ける場合とステータス1列にまとめる場合の違い

観点 フラグを足し続ける ステータス1列にまとめる
取りうる組み合わせ フラグn本で2のn乗通り 定義した値の数だけ
矛盾した行 制約を書かない限り登録できる 1行に1つの値しか入らないので起きない
状態の追加 列の追加(ALTER TABLE ADD COLUMN) 値の追加
「いまの状態」の取り出し 複数の列を組み合わせた条件 WHERE status = '〜'
状態ごとの件数 CASE 式で組み立てる GROUP BY status
向いている場面 互いに独立した性質(包装あり、領収書あり) 1つの流れの中の段階

互いに独立した性質はフラグのままで問題ありません。 「ギフト包装あり」と「領収書あり」は同時に成り立ち、どの組み合わせにも意味があるからです。まとめる対象は、同時に成り立たない状態だけです。

フラグ3本をステータス1列に移す SQL

すでにフラグで運用しているテーブルは、優先順位を決めた CASE 式で1列に移せます。先に書いた条件が優先されるので、「取消は出荷より強い」のような順番を仕様として決めてから書きます。

ALTER TABLE orders_flag ADD COLUMN status varchar(20);
UPDATE orders_flag SET status = CASE
  WHEN is_cancelled THEN 'cancelled'
  WHEN is_shipped   THEN 'shipped'
  WHEN is_paid      THEN 'paid'
  ELSE 'received' END;
SELECT order_id, is_paid, is_shipped, is_cancelled, status
  FROM orders_flag ORDER BY order_id;
UPDATE 3
 order_id | is_paid | is_shipped | is_cancelled |  status  
----------+---------+------------+--------------+----------
        1 | f       | f          | f            | received
        2 | t       | f          | f            | paid
        3 | t       | t          | f            | shipped
(3 rows)

移したあとは、新しい列に NOT NULL と値の制約を付け、アプリケーションの参照先を切り替えてからフラグ列を削除します。

ステータス列の持ち方4通りの比較

ステータス列の定義には、主に4通りの方法があります。

持ち方 不正な値を止める仕組み 値を追加する方法 表示名・並び順の置き場所 変更の経過 向いている場面
1. CHECK制約 CHECK 制約 制約を作り直す アプリケーション側 残らない 値が少なく、ほとんど増えない
2. ENUM型 型そのもの PostgreSQL は ALTER TYPE、MySQL は ALTER TABLE 並び順は定義順、表示名はアプリケーション側 残らない 値の順序で比較・並べ替えをしたい
3. コードマスタ参照 外部キー制約 マスタに1行 INSERT マスタテーブルの列 残らない 値が増える、表示名や属性を持たせたい
4. 状態履歴テーブル 外部キー制約(または CHECK) マスタに1行 INSERT マスタテーブルの列 すべて残る いつ・誰が変えたかを追いたい

1〜3は「いまの状態」を持つ方法で、4は「状態の変化」を持つ方法です。3と4は組み合わせて使えます。

1. CHECK制約で値を決める

PostgreSQL

CREATE TABLE orders_check (
  order_id integer PRIMARY KEY,
  status   varchar(20) NOT NULL DEFAULT 'received'
           CONSTRAINT orders_check_status_ck
           CHECK (status IN ('received', 'paid', 'shipped', 'cancelled'))
);
INSERT INTO orders_check VALUES (1, 'paid');
INSERT INTO orders_check VALUES (2, 'shiped');
INSERT INTO orders_check VALUES (3, NULL);
INSERT 0 1
ERROR:  new row for relation "orders_check" violates check constraint "orders_check_status_ck"
DETAIL:  Failing row contains (2, shiped).
ERROR:  null value in column "status" of relation "orders_check" violates not-null constraint
DETAIL:  Failing row contains (3, null).

つづりを誤った shiped が拒否されました。NOT NULL も必ず付けます。 CHECK 制約は、式の結果が NULL のときは通す決まりなので、NOT NULL が無いと「状態が分からない行」を登録できます。

MySQL

CREATE TABLE orders_check (
  order_id INT PRIMARY KEY,
  status   VARCHAR(20) NOT NULL DEFAULT 'received',
  CONSTRAINT orders_check_status_ck
    CHECK (status IN ('received', 'paid', 'shipped', 'cancelled'))
);
INSERT INTO orders_check VALUES (1, 'paid');
INSERT INTO orders_check VALUES (2, 'shiped');
INSERT INTO orders_check VALUES (3, 'PAID');
SELECT * FROM orders_check;
Query OK, 1 row affected (0.00 sec)
ERROR 3819 (HY000) at line 19: Check constraint 'orders_check_status_ck' is violated.
Query OK, 1 row affected (0.00 sec)
+----------+--------+
| order_id | status |
+----------+--------+
|        1 | paid   |
|        3 | PAID   |
+----------+--------+

MySQL でも shiped は拒否されましたが、大文字の PAID は通りました。 既定の照合順序が大文字と小文字を同じ文字として比べるためです。同じ INSERT を PostgreSQL に流すと、次のように拒否されます。

ERROR:  new row for relation "orders_check" violates check constraint "orders_check_status_ck"
DETAIL:  Failing row contains (9, PAID).

MySQL で大文字と小文字の混在を避けたいときは、登録する側で小文字にそろえるか、後述のコードマスタ参照にして、マスタにある表記だけを使う運用にします。

2. ENUM型で値を決める

PostgreSQL(型を先に作り、列の型として使います)

CREATE TYPE order_status AS ENUM ('received', 'paid', 'shipped', 'cancelled');
CREATE TABLE orders_enum (
  order_id integer PRIMARY KEY,
  status   order_status NOT NULL DEFAULT 'received'
);
INSERT INTO orders_enum VALUES (1, 'paid');
INSERT INTO orders_enum VALUES (2, 'shiped');
INSERT 0 1
ERROR:  invalid input value for enum order_status: "shiped"
LINE 1: INSERT INTO orders_enum VALUES (2, 'shiped');
                                           ^

ENUM 型の特徴は、定義した順番が値の大小になることです。文字列の列と並べ替えの結果を比べます。

INSERT INTO orders_enum  VALUES (3, 'cancelled'), (4, 'received'), (5, 'shipped');
INSERT INTO orders_check VALUES (3, 'cancelled'), (4, 'received'), (5, 'shipped');
SELECT string_agg(status::text, ' < ' ORDER BY status) AS enum_order    FROM orders_enum;
SELECT string_agg(status,       ' < ' ORDER BY status) AS varchar_order FROM orders_check;
              enum_order               
---------------------------------------
 received < paid < shipped < cancelled

             varchar_order             
---------------------------------------
 cancelled < paid < received < shipped

ENUM は定義順、varchar はアルファベット順に並びました。ENUM なら WHERE status >= 'shipped' のような「この段階以降」という条件も書けます。

値の追加は ALTER TYPE で行い、途中に差し込むこともできます。

ALTER TYPE order_status ADD VALUE 'returned' AFTER 'shipped';
SELECT enumsortorder, enumlabel FROM pg_enum
 WHERE enumtypid = 'order_status'::regtype ORDER BY enumsortorder;
ALTER TYPE
 enumsortorder | enumlabel 
---------------+-----------
             1 | received
             2 | paid
             3 | shipped
           3.5 | returned
             4 | cancelled
(5 rows)

一方、値を取り除く構文は用意されていません。

ALTER TYPE order_status DROP VALUE 'returned';
ERROR:  syntax error at or near "VALUE"
LINE 1: ALTER TYPE order_status DROP VALUE 'returned';
                                     ^

PostgreSQL の ENUM は「足せるが、取り除けない」 と考えて、値が入れ替わる見込みのある列には次のコードマスタ参照を選びます。

MySQL(列の型として直接書きます)

CREATE TABLE orders_enum (
  order_id INT PRIMARY KEY,
  status   ENUM('received', 'paid', 'shipped', 'cancelled')
           NOT NULL DEFAULT 'received'
);
INSERT INTO orders_enum VALUES (1, 'paid');
INSERT INTO orders_enum VALUES (2, 'shiped');
Query OK, 1 row affected (0.00 sec)
ERROR 1265 (01000) at line 27: Data truncated for column 'status' at row 1

MySQL の ENUM は、内部では1から始まる番号で持っています。そのため、数値や数字の文字列を渡すと、番号として解釈されます。

INSERT INTO orders_enum VALUES (3, 3);
INSERT INTO orders_enum VALUES (4, '4'), (5, 'received');
SELECT order_id, status, status + 0 AS idx FROM orders_enum ORDER BY status;
+----------+-----------+-----+
| order_id | status    | idx |
+----------+-----------+-----+
|        5 | received  |   1 |
|        1 | paid      |   2 |
|        3 | shipped   |   3 |
|        4 | cancelled |   4 |
+----------+-----------+-----+

3 は3番目の shipped、'4' は4番目の cancelled として登録されました。ENUM の値に数字だけの文字列('1' '2' など)を使うと取り違えやすいので、英単語の値にしておくと安心です。

sql_mode から厳密モードを外した接続では、不正な値がエラーにならず、空文字(番号0)として登録されます。

SET SESSION sql_mode = '';
INSERT INTO orders_enum VALUES (6, 'shiped');
SHOW WARNINGS;
SELECT order_id, status, status + 0 AS idx FROM orders_enum WHERE order_id = 6;
Query OK, 1 row affected, 1 warning (0.00 sec)
+---------+------+---------------------------------------------+
| Level   | Code | Message                                     |
+---------+------+---------------------------------------------+
| Warning | 1265 | Data truncated for column 'status' at row 1 |
+---------+------+---------------------------------------------+
+----------+--------+-----+
| order_id | status | idx |
+----------+--------+-----+
|        6 |        |   0 |
+----------+--------+-----+

MySQL で ENUM を使うときは、sql_mode に STRICT_TRANS_TABLES が入っていること(8.0 の既定値)を確かめておきます。

値の追加は ALTER TABLE で列の定義を書き直します。末尾への追加と途中への追加で、かかる処理が違いました。

-- 末尾に追加
ALTER TABLE orders_enum MODIFY status
  ENUM('received', 'paid', 'shipped', 'cancelled', 'returned')
  NOT NULL DEFAULT 'received', ALGORITHM=INSTANT;
-- 途中に追加
ALTER TABLE orders_enum MODIFY status
  ENUM('received', 'paid', 'shipped', 'on_hold', 'cancelled', 'returned')
  NOT NULL DEFAULT 'received', ALGORITHM=INSTANT;
Query OK, 0 rows affected (0.00 sec)
ERROR 1846 (0A000) at line 1: ALGORITHM=INSTANT is not supported. Reason: Need to rebuild the table to change column type. Try ALGORITHM=COPY/INPLACE.

末尾への追加はテーブルを作り直さずに終わり、途中への追加はテーブルの作り直しが必要でした。ALGORITHM=COPY を指定すると途中にも追加でき、既存の行の番号は新しい定義に合わせて振り直されます(cancelled が4番から5番に変わりました)。

Query OK, 4 rows affected (0.02 sec)
+----------+-----------+-----+
| order_id | status    | idx |
+----------+-----------+-----+
|        5 | received  |   1 |
|        1 | paid      |   2 |
|        3 | shipped   |   3 |
|        4 | cancelled |   5 |
+----------+-----------+-----+

3. コードマスタを参照する

ステータスの値をマスタテーブルに行として持ち、外部キーで参照する方法です。表示名・並び順・「これ以上進まない状態か」といった属性を、値と一緒に持てます。

PostgreSQL

CREATE TABLE order_statuses (
  status_code  varchar(20) PRIMARY KEY,
  status_name  varchar(40) NOT NULL,
  sort_order   integer     NOT NULL UNIQUE,
  is_final     boolean     NOT NULL DEFAULT false
);
INSERT INTO order_statuses VALUES
  ('received',  '受付済み',   10, false),
  ('paid',      '入金済み',   20, false),
  ('shipped',   '出荷済み',   30, true),
  ('cancelled', '取消',       90, true);

CREATE TABLE orders_master (
  order_id    integer PRIMARY KEY,
  status_code varchar(20) NOT NULL DEFAULT 'received'
              REFERENCES order_statuses (status_code)
);
INSERT INTO orders_master VALUES (1, 'paid');
INSERT INTO orders_master VALUES (2, 'shiped');
INSERT 0 1
ERROR:  insert or update on table "orders_master" violates foreign key constraint "orders_master_status_code_fkey"
DETAIL:  Key (status_code)=(shiped) is not present in table "order_statuses".

値の追加は、マスタへの INSERT 1行で済みます。 テーブル定義の変更は要りません。

INSERT INTO order_statuses VALUES ('returned', '返品済み', 40, true);
INSERT INTO orders_master VALUES (2, 'returned');
SELECT o.order_id, s.status_name, s.is_final
  FROM orders_master o JOIN order_statuses s USING (status_code)
 ORDER BY s.sort_order;
 order_id | status_name | is_final 
----------+-------------+----------
        1 | 入金済み    | f
        2 | 返品済み    | t
(2 rows)

使われている値をマスタから消そうとすると、外部キーが止めます。

DELETE FROM order_statuses WHERE status_code = 'paid';
ERROR:  update or delete on table "order_statuses" violates foreign key constraint "orders_master_status_code_fkey" on table "orders_master"
DETAIL:  Key (status_code)=(paid) is still referenced from table "orders_master".

sort_order を 10 刻みにしておくと、あとから間に値を入れやすくなります(返品済みを 40 として、出荷済み 30 と取消 90 の間に入れました)。

MySQL

CREATE TABLE order_statuses (
  status_code VARCHAR(20) PRIMARY KEY,
  status_name VARCHAR(40) NOT NULL,
  sort_order  INT         NOT NULL UNIQUE,
  is_final    BOOLEAN     NOT NULL DEFAULT FALSE
);
INSERT INTO order_statuses VALUES
  ('received',  '受付済み', 10, FALSE),
  ('paid',      '入金済み', 20, FALSE),
  ('shipped',   '出荷済み', 30, TRUE),
  ('cancelled', '取消',     90, TRUE);

CREATE TABLE orders_master (
  order_id    INT PRIMARY KEY,
  status_code VARCHAR(20) NOT NULL DEFAULT 'received',
  CONSTRAINT orders_master_status_fk
    FOREIGN KEY (status_code) REFERENCES order_statuses (status_code)
);
INSERT INTO orders_master VALUES (1, 'paid');
INSERT INTO orders_master VALUES (2, 'shiped');
Query OK, 1 row affected (0.00 sec)
ERROR 1452 (23000) at line 58: Cannot add or update a child row: a foreign key constraint fails (`appdb`.`orders_master`, CONSTRAINT `orders_master_status_fk` FOREIGN KEY (`status_code`) REFERENCES `order_statuses` (`status_code`))

4. 状態履歴テーブルに変化を積む

状態が変わるたびに1行ずつ追加する方法です。いまの状態は、注文ごとの最新の行から求めます。

PostgreSQL

CREATE TABLE order_status_history (
  history_id  bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  order_id    integer     NOT NULL,
  status_code varchar(20) NOT NULL REFERENCES order_statuses (status_code),
  changed_at  timestamptz NOT NULL DEFAULT now(),
  changed_by  varchar(40) NOT NULL
);
CREATE INDEX order_status_history_ix
  ON order_status_history (order_id, changed_at DESC);

INSERT INTO order_status_history (order_id, status_code, changed_at, changed_by) VALUES
  (1, 'received',  '2026-10-01 09:00+09', 'web'),
  (1, 'paid',      '2026-10-01 09:05+09', 'batch'),
  (1, 'shipped',   '2026-10-02 14:30+09', 'sato'),
  (2, 'received',  '2026-10-03 10:00+09', 'web'),
  (2, 'cancelled', '2026-10-03 10:20+09', 'suzuki');
INSERT INTO order_status_history (order_id, status_code, changed_by)
  VALUES (3, 'shiped', 'web');
INSERT 0 5
ERROR:  insert or update on table "order_status_history" violates foreign key constraint "order_status_history_status_code_fkey"
DETAIL:  Key (status_code)=(shiped) is not present in table "order_statuses".

いまの状態と、受付から出荷までにかかった時間を取り出します。

SET timezone = 'Asia/Tokyo';
-- 注文ごとの最新の状態
SELECT DISTINCT ON (order_id) order_id, status_code, changed_at
  FROM order_status_history
 ORDER BY order_id, changed_at DESC;
-- 受付から出荷までの時間
SELECT order_id,
       max(changed_at) FILTER (WHERE status_code = 'shipped')
     - min(changed_at) FILTER (WHERE status_code = 'received') AS lead_time
  FROM order_status_history GROUP BY order_id ORDER BY order_id;
 order_id | status_code |       changed_at       
----------+-------------+------------------------
        1 | shipped     | 2026-10-02 14:30:00+09
        2 | cancelled   | 2026-10-03 10:20:00+09
(2 rows)

 order_id |   lead_time    
----------+----------------
        1 | 1 day 05:30:00
        2 | 
(2 rows)

「いつ・誰が・どの状態にしたか」が残るので、段階ごとの所要時間のような集計ができます。 これは、いまの状態だけを持つ1〜3の方法では取り出せない情報です。

MySQL

CREATE TABLE order_status_history (
  history_id  BIGINT AUTO_INCREMENT PRIMARY KEY,
  order_id    INT         NOT NULL,
  status_code VARCHAR(20) NOT NULL,
  changed_at  DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  changed_by  VARCHAR(40) NOT NULL,
  KEY order_status_history_ix (order_id, changed_at),
  CONSTRAINT order_status_history_status_fk
    FOREIGN KEY (status_code) REFERENCES order_statuses (status_code)
);
-- (PostgreSQL と同じ5行を登録したあと)注文ごとの最新の状態
SELECT order_id, status_code, changed_at FROM (
  SELECT h.*, ROW_NUMBER() OVER (
           PARTITION BY order_id ORDER BY changed_at DESC, history_id DESC) AS rn
    FROM order_status_history h
) t WHERE rn = 1 ORDER BY order_id;
+----------+-------------+----------------------------+
| order_id | status_code | changed_at                 |
+----------+-------------+----------------------------+
|        1 | shipped     | 2026-10-02 14:30:00.000000 |
|        2 | cancelled   | 2026-10-03 10:20:00.000000 |
+----------+-------------+----------------------------+

MySQL には DISTINCT ON が無いので、ROW_NUMBER で注文ごとの最新行を取り出しました。

一覧画面のように「いまの状態」を頻繁に検索する場合は、注文テーブルにも status_code 列を持ち、履歴への追加と同じトランザクションで更新する形がよく使われます。その場合は、注文テーブルの列を「いまの状態」、履歴テーブルを「変化の記録」と役割を分けて定義書に書いておきます。

日時列・履歴テーブルの使い分け

「いつ変わったか」の持ち方は、必要な細かさで決めます。

知りたいこと 持ち方 例
いまの状態だけ ステータス列 status
ある状態になった日時(1回きり) 日時列 shipped_at(NULL なら出荷前)
いまの状態と、最後に変わった日時 ステータス列+日時列 status と status_changed_at
変わった経過のすべてと、変えた人 状態履歴テーブル order_status_history

「出荷済みフラグ」と「出荷日時」の両方を持つと、フラグは立っているのに日時が空、という食い違いが起こりえます。 日時列があれば「日時が入っている=その状態になった」と読めるので、フラグは省けます。差し戻しのように同じ状態を2回以上通る業務では、日時列1つでは最後の1回しか残らないため、履歴テーブルを選びます。

コード値の定義書テンプレート

ステータスや区分の値は、DDL とは別に、意味と使い方を表にまとめておくと、画面・帳票・他システムとの連携で同じ理解を共有できます。架空の注文管理を題材にした記入例です。

列の定義

項目 記入例
テーブル名・列名 orders.status_code
論理名 注文ステータス
種類 ステータス(順序あり・遷移あり)
型・桁 varchar(20)、NOT NULL
既定値 received
値を止める仕組み order_statuses への外部キー
変化の記録 order_status_history に1行追加
値を追加するときの手順 マスタに INSERT、この定義書に追記、遷移の表を更新

コード値の一覧

コード値 表示名 意味(この値になる条件) 並び順 最終状態 次に進める値 追加日
received 受付済み 注文を受け付け、入金を待っている 10 いいえ paid、cancelled 2026-10-05
paid 入金済み 入金を確認し、出荷を待っている 20 いいえ shipped、cancelled 2026-10-05
shipped 出荷済み 倉庫から出荷した 30 いいえ returned 2026-10-05
returned 返品済み 出荷後に品物が戻り、受け取りを確認した 40 はい なし 2026-10-05
cancelled 取消 出荷前に注文を取り消した 90 はい なし 2026-10-05

表計算ソフトに貼れるタブ区切りです。

コード値    表示名 意味(この値になる条件)    並び順 最終状態    次に進める値  追加日
received    受付済み    注文を受け付け、入金を待っている    10  いいえ paid、cancelled  2026-10-05
paid    入金済み    入金を確認し、出荷を待っている 20  いいえ shipped、cancelled   2026-10-05
shipped 出荷済み    倉庫から出荷した    30  いいえ returned    2026-10-05
returned    返品済み    出荷後に品物が戻り、受け取りを確認した 40  はい  なし  2026-10-05
cancelled   取消  出荷前に注文を取り消した    90  はい  なし  2026-10-05

書くときのポイントは3つです。

  • 「意味」の欄は、その値になる条件を1文で書きます。 「処理中」のような幅のある名前は、人によって解釈が分かれるので、条件まで書いておきます。
  • 「次に進める値」を書くと、定義書だけで遷移の抜けに気づけます。 どこからも進めない値や、どこへも進めないのに最終状態でない値が見つかります。
  • 使わなくなった値は消さずに残します。 過去の行がその値を持っているためです。
  • 値を足したら、既存の値の属性も見直します。 この例では「返品済み」を足したことで、「出荷済み」は最終状態ではなくなりました。

状態カラムのアンチパターンと直し方

よく見る形 起きること 直し方
同時に成り立たない状態を複数のフラグで持つ 矛盾した行を登録できる ステータス1列にまとめる
〜_flag という名前の列に 0・1・2 が入っている 読む人が2値だと受け取る 列名を 〜_type や status に改め、値の制約を付ける
ステータス列に制約が無い つづりの違う値や空文字が混ざる CHECK・ENUM・外部キーのどれかを付ける
ステータス列が NULL を許している 「状態なし」の意味が人によって変わる NOT NULL と既定値を付ける
数字のコード(1・2・3)だけで意味がどこにも書かれていない SQL を読んでも状態が分からない 英単語のコードにするか、コードマスタと定義書を用意する
1つの列に2つの流れを混ぜる(入金の状態と配送の状態) 値の数が掛け算で増える 流れごとに列を分ける(payment_status と shipping_status)
フラグと日時を二重に持つ フラグと日時が食い違う 日時列だけにする

「1つの列に2つの流れを混ぜる」は、フラグを足し続けるのと逆向きのつまずきです。入金と配送のように別々に進む流れは、それぞれが1列のステータスになります。「1列にまとめる」のは、1つの流れの中の段階だけです。

確かめて分かったこと

  • MySQL の BOOLEAN は TINYINT(1) として作られ、2 も登録できました。 INSERT INTO orders_flag VALUES (7, 2, 0, 0) はエラーにならず、is_paid に 2 が入ります。MySQL でフラグを厳密に2値にしたいときは、CHECK (is_paid IN (0, 1)) を付けます。
  • MySQL の CHECK 制約は、既定の照合順序では大文字の 'PAID' を通しました。 同じ値は PostgreSQL では拒否されます。
  • MySQL の ENUM に数値の 3 や文字列の '4' を渡すと、3番目・4番目の値として登録されました。
  • MySQL で厳密モードを外すと、ENUM の不正な値は警告つきで空文字(番号0)として登録されました。
  • MySQL の ENUM は、末尾への値の追加なら ALGORITHM=INSTANT で通り、途中への追加はテーブルの作り直しが必要でした。
  • PostgreSQL の ENUM は途中に値を追加でき(並び順の内部値が 3.5 になりました)、取り除く構文は構文エラーになりました。

よくある質問

Q. フラグとステータスの違いは何ですか? A. フラグは2値、ステータスは順序と遷移を持つ3つ以上の値です。「ある・ない」で言い切れて、ほかの状態と独立しているならフラグ、段階を進んでいくものならステータスにします。

Q. 区分とステータスの違いは何ですか? A. 値に順序と遷移があるかどうかです。配送方法のように登録時に決まって並びに意味のないものが区分、注文の進み具合のように時間とともに決まった向きへ変わるものがステータスです。

Q. ステータスは数値と文字列のどちらで持つのがよいですか? A. 読みやすさを優先するなら英単語の文字列です。WHERE status = 'shipped' は定義書を見なくても意味が分かります。数値にする場合は、コードマスタと定義書を必ず用意します。

Q. ENUM型とコードマスタはどちらを選べばよいですか? A. 値が増えたり入れ替わったりする見込みがあるならコードマスタです。表示名や並び順も一緒に持てて、追加が INSERT 1行で済みます。値が固定で、定義順の大小比較を使いたいときは ENUM が合います。

Q. ステータスをいつ変えたかを残したいときはどうしますか? A. 1回分で足りるなら日時列、経過のすべてが要るなら状態履歴テーブルを使います。差し戻しのように同じ状態を何度も通る業務は、履歴テーブルが向いています。

まとめ

  • 2値で増えないならフラグ、並びに意味のない分類は区分、順序と遷移があるならステータスと使い分けます。
  • 同時に成り立たない状態を複数のフラグで持つと、矛盾した行を登録できます。 フラグ3本なら8通りのうち4通りが説明のつかない組み合わせでした。
  • ステータス列の持ち方は CHECK制約・ENUM型・コードマスタ参照・状態履歴テーブルの4通りで、値が増えるならコードマスタ、経過を追うなら履歴テーブルを選びます。
  • ステータス列には NOT NULL と値の制約を必ず付けます。 不正な値は登録の時点でエラーになります。
  • MySQL では BOOLEAN に 2 が入る、CHECK が大文字を通す、ENUM が数値を番号として受け取る点を押さえておきます。
  • コード値の定義書には「その値になる条件」と「次に進める値」を書くと、遷移の抜けに気づけます。

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

(記載例)出典:株式会社RJC「ステータス設計:フラグ・区分・ステータスの違いとテーブル定義例」

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