はじめに
これまでの記事では、データベース設計について、
- 必要なデータの洗い出し
- テーブル・カラム設計
- テーブル同士のリレーション
- 正規化
- PRIMARY KEY・FOREIGN KEY・UNIQUEなどの制約
- データ型
- インデックス
- 論理削除・物理削除
といった内容を見てきました。
今回は、さらに実務寄りのテーマとして、
「データが変更されたとき、過去の値をどう残すのか」
という履歴データの設計について考えていきます。
システムでは、データが一度登録されたら永久に変わらないとは限りません。
例えば、
- 商品の価格が変わる
- ユーザーの住所が変わる
- 契約内容が変わる
- 商品のステータスが変わる
- 社員の所属部署が変わる
といったことがあります。
単純に現在の値だけが分かればよいのであれば、既存のカラムを更新するだけでも問題ありません。
しかし、
「この商品は先月いくらだったのか?」
「この契約は2026年4月時点ではどのような内容だったのか?」
といった情報が必要になる場合、現在の値だけを保存する設計では対応できません。
今回は商品の価格変更を例に、履歴データをどのように設計するのかを考えていきます。
この記事の対象者
この記事は、次のような人を対象にしています。
- エンジニアとして開発しているが、データベースについてあまり詳しくない人
- SQLを書いた経験はあるものの、テーブル設計の経験が少ない人
- データの更新は経験しているが、「過去の値を残す設計」をあまり考えたことがない人
- 履歴テーブルを作ったことはあるものの、どのような場合に必要なのか整理したい人
SQLの基本的な知識があると理解しやすいですが、履歴管理の経験がなくても問題ありません。
値を書き換えるだけでは何が問題なのか
まず、単純な商品テーブルを考えてみます。
products
+----+----------+-------+
| id | name | price |
+----+----------+-------+
| 1 | 商品A | 1000 |
+----+----------+-------+
商品Aの価格は現在、
1000円
だとします。
そして翌日、価格が
1200円
へ変更されたとします。
現在の価格だけを管理するのであれば、
products.price
を書き換えればよいため、
products
+----+----------+-------+
| id | name | price |
+----+----------+-------+
| 1 | 商品A | 1200 |
+----+----------+-------+
となります。
一見すると、特に問題はありません。
しかし、この更新によって、
昨日:1000円
今日:1200円
という情報のうち、
昨日:1000円
という情報はデータベースから失われています。
そのため、あとから、
「2026年9月28日時点の商品Aの価格はいくらだったのか?」
と聞かれても、データベースから調べることができません。
「現在の値」と「過去の値」は別の情報
ここで重要なのは、
現在の値と、過去にどのような値だったのかという情報は別物
だということです。
例えば、
商品A
現在価格:1200円
という情報と、
2026-09-01 ~ 2026-09-28:1000円
2026-09-29 ~ 現在 :1200円
という情報では、持っている意味が違います。
前者は、
「今いくらなのか」
を表しています。
後者は、
「いつ、いくらだったのか」
を表しています。
システムで後者が必要なのであれば、単純なUPDATEだけでは十分ではありません。
そこで、履歴を保存するためのテーブルを考えます。
価格履歴テーブルを作る
例えば、商品価格の履歴を管理するために、
product_prices
というテーブルを作ることができます。
product_prices
+------------+-------+------------+------------+
| product_id | price | valid_from | valid_to |
+------------+-------+------------+------------+
| 1 | 1000 | 2026-09-01 | 2026-09-28 |
| 1 | 1200 | 2026-09-29 | NULL |
+------------+-------+------------+------------+
それぞれのカラムは、例えば次のような意味になります。
product_id
どの商品についての価格なのか
price
その期間の価格
valid_from
その価格が有効になった日時
valid_to
その価格が有効でなくなった日時
valid_to が NULL のデータを、
現在も有効
と扱う設計にすることもできます。
すると、
商品A
2026-09-01 ~ 2026-09-28
1000円
2026-09-29 ~ 現在
1200円
という価格の変化をデータとして残せます。
productsとproduct_pricesの関係
ここまでの構造を整理すると、
products
│
│ 1
│
└──── N
product_prices
という関係になります。
1つの商品に対して、複数の価格履歴が存在するため、
1対多
の関係です。
例えば、
products
+----+----------+
| id | name |
+----+----------+
| 1 | 商品A |
+----+----------+
に対して、
product_prices
+----+------------+-------+------------+------------+
| id | product_id | price | valid_from | valid_to |
+----+------------+-------+------------+------------+
| 1 | 1 | 1000 | 2026-09-01 | 2026-09-28 |
| 2 | 1 | 1200 | 2026-09-29 | NULL |
+----+------------+-------+------------+------------+
のような形です。
product_prices.product_idには、
products.id
を参照する外部キーを設定することが考えられます。
こうすることで、
存在しない商品の価格履歴が登録されてしまう
といったデータの不整合を防ぎやすくなります。
外部キーについては、第4回や第6回でも扱いました。
価格を変更するときはどうするのか
履歴を残さない設計では、価格変更は単純です。
例えば、
UPDATE products
SET price = 1200
WHERE id = 1;
とすれば価格を変更できます。
一方、履歴管理をする場合は考え方が変わります。
現在、
1000円
2026-09-01 ~ 現在
というデータがあるとします。
+------------+-------+------------+----------+
| product_id | price | valid_from | valid_to |
+------------+-------+------------+----------+
| 1 | 1000 | 2026-09-01 | NULL |
+------------+-------+------------+----------+
2026年9月29日から1200円に変更するのであれば、
まず現在の1000円の期間を終了させ、
1000円
2026-09-01 ~ 2026-09-28
とします。
そして、新しい履歴として、
1200円
2026-09-29 ~ 現在
を追加します。
結果として、
+------------+-------+------------+------------+
| product_id | price | valid_from | valid_to |
+------------+-------+------------+------------+
| 1 | 1000 | 2026-09-01 | 2026-09-28 |
| 1 | 1200 | 2026-09-29 | NULL |
+------------+-------+------------+------------+
となります。
つまり、履歴管理では単純に、
古い値を新しい値で上書きする
のではなく、
古い値の有効期間を終了する
↓
新しい値を追加する
という考え方になります。
valid_fromとvalid_to
履歴管理では、
valid_from
valid_to
のような有効期間を持たせる設計がよく使われます。
例えば、
+-------+---------------------+---------------------+
| price | valid_from | valid_to |
+-------+---------------------+---------------------+
| 1000 | 2026-09-01 00:00:00 | 2026-09-29 00:00:00 |
| 1200 | 2026-09-29 00:00:00 | NULL |
+-------+---------------------+---------------------+
という形です。
この場合、
valid_from <= 対象日時
かつ、
対象日時 < valid_to
というルールで有効期間を判断できます。
valid_to が NULL の場合は、
終了日時がまだ決まっていない
=現在も有効
という扱いです。
日付だけで管理する方法もありますが、価格が1日の途中で変更される可能性があるシステムでは、日時で管理する必要があります。
何を保存するべきかは、システムの要件によって変わります。
「いつからいつまで」をどう定義するか
ここは履歴設計で特に注意したい部分です。
例えば、
1000円
valid_from = 2026-09-01 00:00:00
valid_to = 2026-09-29 00:00:00
というデータがある場合、
valid_toの時刻そのものを含むのかどうかを決めておく必要があります。
実務では例えば、
valid_from <= 対象日時 < valid_to
のように、
開始日時は含むが、終了日時は含まない
というルールにすると扱いやすくなります。
すると、
1000円
2026-09-01 00:00:00
以上
2026-09-29 00:00:00
未満
となり、次の価格を、
1200円
2026-09-29 00:00:00
以上
とすることで、
1000円の終了時刻
=
1200円の開始時刻
とできます。
期間の境界が曖昧になるのを防ぐためにも、
「valid_toを含むのか、含まないのか」
といったルールはあらかじめ決めておいたほうがよいでしょう。
過去の価格を検索する
履歴データを保存していれば、
「2026年9月15日の商品Aはいくらだったか」
といった検索もできるようになります。
概念的には、
SELECT price
FROM product_prices
WHERE product_id = 1
AND valid_from <= '2026-09-15'
AND (
valid_to > '2026-09-15'
OR valid_to IS NULL
);
のような検索です。
この場合、
1000円
が取得されます。
一方、
2026-09-30
の価格を検索すれば、
1200円
が取得されます。
つまり履歴テーブルを作ることで、
「現在の状態」だけではなく「指定した時点の状態」
を調べられるようになります。
現在の価格はどこに持つのか
ここで設計上の疑問が出てきます。
products.priceは残したほうがよいのでしょうか?
例えば、
products
id
name
price
と、
product_prices
product_id
price
valid_from
valid_to
の両方に価格を持つ設計です。
これはシステムによって判断が分かれます。
product_pricesだけで管理する方法
例えば、
products
id
name
として、
価格はすべて、
product_prices
で管理します。
現在価格が欲しい場合は、
valid_to IS NULL
のデータを取得します。
イメージとしては、
SELECT price
FROM product_prices
WHERE product_id = 1
AND valid_to IS NULL;
です。
この方法では、価格を保存する場所が1か所になるため、
products.price = 1200
product_pricesの現在価格 = 1000
のように、2つの価格が食い違う問題を避けやすくなります。
一方で、商品一覧などで現在価格を頻繁に利用する場合、毎回履歴テーブルを参照する必要があります。
そのため、検索方法やインデックスについても考える必要があります。
productsにも現在価格を持つ方法
もう一つは、
products.price
に現在価格を保存しながら、
product_prices
にも履歴を保存する方法です。
products
+----+----------+-------+
| id | name | price |
+----+----------+-------+
| 1 | 商品A | 1200 |
+----+----------+-------+
product_prices
+------------+-------+------------+------------+
| product_id | price | valid_from | valid_to |
+------------+-------+------------+------------+
| 1 | 1000 | 2026-09-01 | 2026-09-28 |
| 1 | 1200 | 2026-09-29 | NULL |
+------------+-------+------------+------------+
こうすると、
現在価格 → products.price
過去価格 → product_prices
という使い分けができます。
現在価格を頻繁に参照するシステムでは便利な場合があります。
しかし、
同じ意味の現在価格を2か所に保存する
ことになるため注意が必要です。
例えば更新処理に失敗して、
products.price = 1200
product_pricesの現在価格 = 1000
となれば、
「現在価格はどちらが正しいのか?」
という問題が起きます。
そのため、この方法を採用する場合は、
どちらを正しいデータとして扱うのか
を明確にしたうえで、更新を同一トランザクションで行うなど、整合性を維持する仕組みが必要になります。
単純に、
「検索が楽そうだから両方に入れておこう」
という理由だけで重複して持つのは避けたほうがよいでしょう。
履歴データで注意したい「期間の重複」
履歴テーブルを作れば、それだけで正しい履歴管理ができるわけではありません。
例えば、次のデータが登録されてしまったらどうでしょうか。
+-------+------------+------------+
| price | valid_from | valid_to |
+-------+------------+------------+
| 1000 | 2026-09-01 | 2026-09-30 |
| 1200 | 2026-09-20 | NULL |
+-------+------------+------------+
2026年9月20日から9月29日まで、
1000円
と
1200円
の両方が有効になっています。
すると、
「2026年9月25日の価格はいくらなのか?」
という問いに対して、2つの答えが存在してしまいます。
これでは履歴として正しくありません。
基本的には、同じ商品の価格履歴について、
期間が重複しない
ようにする必要があります。
例えば、
1000円
2026-09-01 ~ 2026-09-29
1200円
2026-09-29 ~ 現在
のようにつなげます。
ただし、期間の重複をデータベースの制約だけで完全に防ぐ方法は、利用しているデータベース製品によって異なります。
そのため、
- DB側の制約
- 更新処理
- トランザクション
- アプリケーション側のチェック
などを組み合わせて、履歴の整合性を守ることを考える必要があります。
現在有効なデータが複数存在しないようにする
もう一つ注意したいのが、
valid_to IS NULL
のデータです。
例えば、
+------------+-------+------------+----------+
| product_id | price | valid_from | valid_to |
+------------+-------+------------+----------+
| 1 | 1200 | 2026-09-29 | NULL |
| 1 | 1300 | 2026-10-01 | NULL |
+------------+-------+------------+----------+
となってしまうと、
現在有効な価格
が2件存在します。
これも、
「現在価格はいくらなのか?」
が分からなくなる原因になります。
そのため、
1商品につき現在有効な価格は原則1件
というルールも考える必要があります。
履歴テーブルを設計するときは、
単に、
valid_from
valid_to
を追加するだけではなく、
どのような状態を「正しい履歴」とするのか
まで考えることが重要です。
未来の価格を登録したい場合
システムによっては、
「来月から価格を1500円にする」
という情報を事前に登録したい場合があります。
例えば、
+-------+---------------------+---------------------+
| price | valid_from | valid_to |
+-------+---------------------+---------------------+
| 1200 | 2026-09-29 00:00:00 | 2026-10-01 00:00:00 |
| 1500 | 2026-10-01 00:00:00 | NULL |
+-------+---------------------+---------------------+
とすれば、
現在:1200円
2026年10月1日から:1500円
という予約された価格変更も表現できます。
このように、
valid_from
valid_to
は単なる「過去の履歴」だけではなく、
いつからそのデータを有効にするのか
を表す仕組みとして利用できます。
注文時の価格はどうするのか
価格履歴を考えるとき、ECサイトではもう一つ重要な問題があります。
例えば、あるユーザーが商品Aを、
1000円
のときに購入したとします。
その後、商品Aの価格が、
1200円
になりました。
このとき注文履歴には、どちらの価格を表示するべきでしょうか。
当然、
購入時の1000円
です。
そこで、
order_items
などの注文明細には、注文時点の価格を保存することがあります。
例えば、
order_items
+----------+------------+----------+-------+
| order_id | product_id | quantity | price |
+----------+------------+----------+-------+
| 1001 | 1 | 1 | 1000 |
+----------+------------+----------+-------+
です。
一見すると、
商品価格はproduct_pricesにあるのだから、
注文テーブルにpriceを持つのは重複では?
と思うかもしれません。
しかし、このpriceは意味が違います。
product_prices.price
→ その期間の商品価格
に対して、
order_items.price
→ その注文で実際に確定した価格
です。
例えば、
- セール
- クーポン
- 会員割引
- 個別値引き
- キャンペーン
などによって、商品の通常価格と実際の販売価格が違うこともあります。
そのため注文では、
「注文時点で確定した事実」
として価格を保存することが重要になります。
これは単なる履歴管理とは少し違い、
取引時点の情報をスナップショットとして残す
という考え方です。
何でも履歴にすればよいわけではない
ここまで見ると、
「変更される可能性があるデータは、全部履歴にすれば安全なのでは?」
と思うかもしれません。
しかし、履歴管理にもコストがあります。
履歴を保存すると、
- データ件数が増える
- テーブル構造が複雑になる
- INSERT・UPDATE処理が複雑になる
- 現在値を取得するSQLが複雑になる
- 過去時点の検索条件を考える必要がある
- インデックス設計も必要になる
- 履歴同士の期間が重複しないよう管理する必要がある
といった問題が発生します。
そのため、
変更されるデータ
=
必ず履歴を残す
ではありません。
履歴を残すべきかどうかを考える
履歴管理が必要かどうかは、要件から考えます。
例えば、次のような場合は履歴が必要になる可能性があります。
過去時点の状態を確認する必要がある
2026年4月1日時点の契約内容を確認したい
変更前の値を確認する必要がある
商品の価格がいつ、いくらからいくらへ変わったのか知りたい
業務上の証跡として必要
当時どの条件で契約していたのか残しておかなければならない
将来の変更を予約したい
来月1日から新料金を適用する
一方、
過去の値を確認する必要がなく、
常に最新の値だけ分かればよい
のであれば、通常のUPDATEで十分な場合もあります。
重要なのは、
「変更されるから履歴を作る」のではなく、「過去の状態を利用する必要があるから履歴を作る」
という考え方です。
updated_atは履歴ではない
テーブルには、
created_at
updated_at
のようなカラムを持たせることがあります。
例えば、
products
id
name
price
created_at
updated_at
です。
updated_atを見ることで、
最後にいつ更新されたのか
は分かります。
しかし、
1000円 → 1200円
に変更された場合、
updated_atだけでは、
以前はいくらだったのか
は分かりません。
つまり、
updated_at
は、
「最後に更新された日時」
を保存するものであり、
変更前のデータそのものを保存する履歴ではありません。
ここは混同しやすいので注意が必要です。
履歴と監査ログも少し違う
もう一つ似たものとして、
監査ログ
があります。
例えば、
誰が
いつ
何を
どの値から
どの値へ変更したのか
を保存する仕組みです。
例えば、
product_price_change_logs
product_id
old_price
new_price
changed_by
changed_at
のようなデータです。
これは、
「変更操作の記録」
を残すことが目的です。
一方、
product_prices
のような履歴テーブルは、
「ある時点でどの状態だったのか」
を表すことが主な目的です。
似ていますが、
履歴データ
→ その時点の状態を知りたい
監査ログ
→ 誰がどのような変更をしたのか知りたい
という違いがあります。
システムによっては両方必要になることもあります。
論理削除との違い
前回の記事では、
deleted_at
などを利用した論理削除について説明しました。
論理削除も、
過去のデータを物理的には残す
という点では履歴管理に似ています。
しかし目的は異なります。
論理削除は基本的に、
このデータは現在利用できるのか
それとも削除扱いなのか
を管理するものです。
例えば、
users
id
name
deleted_at
で、
deleted_at IS NULL
なら利用中、
deleted_at IS NOT NULL
なら削除済み、
と判断します。
一方、今回扱っている履歴管理は、
あるデータがいつからいつまで、
どのような値だったのか
を管理するものです。
そのため、
deleted_atを付ければ履歴管理になる
わけではありません。
データを残す目的によって、設計を使い分ける必要があります。
履歴テーブルにもインデックスを考える
履歴データは、時間が経つほど増えていきます。
例えば、10万商品について価格変更が何度も行われれば、
product_prices
には大量のデータが蓄積されます。
その状態で、
特定商品の現在価格を探す
特定商品の指定日時の価格を探す
といった検索を頻繁に行うのであれば、インデックスも重要になります。
例えば検索条件として、
product_id
valid_from
valid_to
などが使われる可能性があります。
ただし、第8回でも説明したように、
インデックスは付ければ付けるほどよいわけではありません。
実際にどのようなSQLで検索するのかを確認しながら設計する必要があります。
履歴管理では「何を残したいのか」から考える
履歴テーブルを作るとき、
とりあえず変更前の値を全部保存しておこう
と考えるのではなく、まず要件を整理することが重要です。
例えば商品価格なら、
過去の価格を確認したいのか
価格が変更された日時を知りたいのか
未来の価格変更も予約したいのか
誰が価格を変更したのかも知りたいのか
注文時点の販売価格も残す必要があるのか
によって、必要なデータは変わります。
これはシリーズ第2回で扱った、
「要件から必要なデータを洗い出す」
という考え方と同じです。
履歴管理だから特別な設計方法を使うというより、
「システムで何を後から確認する必要があるのか」
を考え、そのために必要なデータ構造を設計することが重要です。
今回のポイント
今回は、履歴データの設計について見てきました。
ポイントを整理すると、
現在値をUPDATEするだけでは過去の値は失われる
過去の状態が必要なら履歴テーブルを検討する
valid_from / valid_toなどで有効期間を表現できる
同じ期間の履歴が重複しないようにする
現在有効なデータが複数存在しないようにする
productsとproduct_pricesのように1対多で管理できる
注文時の価格などは「その時点で確定した事実」として別途保存することがある
updated_atは変更日時であって履歴そのものではない
履歴と監査ログは目的が異なる
何でも履歴にするのではなく、要件から必要性を判断する
という点が重要です。
データベース設計では、
「今のデータをどう保存するか」
だけではなく、
「データが変化したとき、その変化をどこまで残す必要があるのか」
も考える必要があります。
商品価格のように、
昨日:1000円
今日:1200円
と変化するデータについて、
products.price = 1200
と上書きするだけでよいのか、
それとも、
product_prices
product_id
price
valid_from
valid_to
のように履歴として残す必要があるのか。
これはデータベースだけで決める問題ではなく、
「システムとして過去の状態をどこまで必要とするのか」
という業務要件から考えることが重要です。