Skip to content

MySQL Tutorial Real-World Patterns

Price History and Temporal Data

Lesson 277 of 308 3 min read By Deepak Paswan Updated Oct 5, 2026

Some values change over time, and you need to know what they were, not only what they are now: a product’s price, an employee’s salary, a customer’s plan. Overwriting the old value loses the past. A history table keeps it.

The model: one row per period

query.sql
SQL
CREATE TABLE products (id INT PRIMARY KEY, name VARCHAR(30) NOT NULL);
INSERT INTO products VALUES (1, 'Notebook'), (2, 'Pen');

CREATE TABLE product_prices (
    id INT AUTO_INCREMENT PRIMARY KEY,
    product_id INT NOT NULL,
    price DECIMAL(8,2) NOT NULL,
    valid_from DATE NOT NULL,
    valid_to DATE NULL,
    FOREIGN KEY (product_id) REFERENCES products (id),
    KEY idx_product_period (product_id, valid_from),
    CONSTRAINT chk_period CHECK (valid_to IS NULL OR valid_to > valid_from)
);

INSERT INTO product_prices (product_id, price, valid_from, valid_to) VALUES
(1, 70.00, '2025-01-01', '2025-07-01'),
(1, 80.00, '2025-07-01', '2026-01-01'),
(1, 90.00, '2026-01-01', NULL),
(2, 10.00, '2025-01-01', NULL);
Output
OUTPUT
Query OK, 0 rows affected

Query OK, 2 rows affected
Records: 2  Duplicates: 0  Warnings: 0

Query OK, 0 rows affected

Query OK, 4 rows affected
Records: 4  Duplicates: 0  Warnings: 0

Each row says: “from valid_from (including) up to valid_to (excluding), the price was X.” valid_to IS NULL means “still valid”. Using half-open periods (start included, end excluded) means the end of one period is exactly the start of the next, with no gap and no overlap.

The price on a given date (as-of query)

query.sql
SQL
SELECT p.name, pp.price
FROM products p
JOIN product_prices pp ON pp.product_id = p.id
WHERE pp.valid_from <= '2025-09-15' AND (pp.valid_to IS NULL OR pp.valid_to > '2025-09-15')
ORDER BY p.name;
Output
OUTPUT
+----------+-------+
| name     | price |
+----------+-------+
| Notebook | 80.00 |
| Pen      | 10.00 |
+----------+-------+
2 rows in set

The same query with another date gives the price at that moment. This is how you re-create an old invoice, or answer “what was the price when the customer ordered?” (If an invoice needs to keep the price forever, the order line also stores a snapshot, see Denormalization.)

The current price

query.sql
SQL
SELECT p.name, pp.price AS current_price
FROM products p JOIN product_prices pp ON pp.product_id = p.id AND pp.valid_to IS NULL
ORDER BY p.name;
Output
OUTPUT
+----------+---------------+
| name     | current_price |
+----------+---------------+
| Notebook |         90.00 |
| Pen      |         10.00 |
+----------+---------------+
2 rows in set

Make it a view, so that application code reads current_prices and never has to remember the valid_to IS NULL rule (Views).

query.sql
SQL
CREATE VIEW current_prices AS
SELECT product_id, price, valid_from FROM product_prices WHERE valid_to IS NULL;

SELECT * FROM current_prices ORDER BY product_id;
Output
OUTPUT
+------------+-------+------------+
| product_id | price | valid_from |
+------------+-------+------------+
|          1 | 90.00 | 2026-01-01 |
|          2 | 10.00 | 2025-01-01 |
+------------+-------+------------+
2 rows in set

Changing the price: close the old row, open a new one

Both steps must happen together, so use a transaction, and lock the current row first so that two changes cannot interleave (Transactions):

query.sql
SQL
START TRANSACTION;
SELECT id FROM product_prices WHERE product_id = 1 AND valid_to IS NULL FOR UPDATE;
UPDATE product_prices SET valid_to = '2026-03-01' WHERE product_id = 1 AND valid_to IS NULL;
INSERT INTO product_prices (product_id, price, valid_from, valid_to) VALUES (1, 95.00, '2026-03-01', NULL);
COMMIT;

SELECT price, valid_from, valid_to FROM product_prices WHERE product_id = 1 ORDER BY valid_from;
Output
OUTPUT
+----+
| id |
+----+
|  3 |
+----+
1 row in set

+-------+------------+------------+
| price | valid_from | valid_to   |
+-------+------------+------------+
| 70.00 | 2025-01-01 | 2025-07-01 |
| 80.00 | 2025-07-01 | 2026-01-01 |
| 90.00 | 2026-01-01 | 2026-03-01 |
| 95.00 | 2026-03-01 | NULL       |
+-------+------------+------------+
4 rows in set

The history is continuous: each valid_to equals the next valid_from.

Finding gaps and overlaps

The database does not stop two overlapping periods for the same product by itself. Check with a query (and run it in tests or a scheduled job):

query.sql
SQL
INSERT INTO product_prices (product_id, price, valid_from, valid_to) VALUES (2, 11.00, '2025-06-01', '2025-08-01');   -- a mistake: overlaps the open period

SELECT a.product_id, a.id AS first_id, b.id AS second_id
FROM product_prices a
JOIN product_prices b ON b.product_id = a.product_id AND b.id > a.id
 AND a.valid_from < IFNULL(b.valid_to, '9999-12-31') AND b.valid_from < IFNULL(a.valid_to, '9999-12-31')
ORDER BY a.product_id, a.id;
Output
OUTPUT
+------------+----------+-----------+
| product_id | first_id | second_id |
+------------+----------+-----------+
|          2 |        4 |         6 |
+------------+----------+-----------+
1 row in set

Two periods overlap when each starts before the other ends. IFNULL(valid_to, '9999-12-31') treats an open period as “forever”. To make the database refuse overlaps outright, use a trigger that runs this check in a BEFORE INSERT, or lock the product row and check in the application (Triggers). MySQL has no built-in exclusion constraint.

Find gaps (a period that does not start where the previous one ended):

query.sql
SQL
DELETE FROM product_prices WHERE product_id = 2 AND price = 11.00;

SELECT product_id, valid_from, LAG(valid_to) OVER (PARTITION BY product_id ORDER BY valid_from) AS previous_end
FROM product_prices
ORDER BY product_id, valid_from;
Output
OUTPUT
+------------+------------+--------------+
| product_id | valid_from | previous_end |
+------------+------------+--------------+
|          1 | 2025-01-01 | NULL         |
|          1 | 2025-07-01 | 2025-07-01   |
|          1 | 2026-01-01 | 2026-01-01   |
|          1 | 2026-03-01 | 2026-03-01   |
|          2 | 2025-01-01 | NULL         |
+------------+------------+--------------+
5 rows in set

On each row after the first of a product, previous_end should equal valid_from. A different value is a gap (or an overlap). This uses a window function (LAG).

Other history patterns

PatternHowUse when
Valid-time table (above)valid_from / valid_to columns on the tableThe business needs to know what was true when (prices, plans, contracts)
Audit table with triggersCopy old values to a log table on every changeYou need who and when changed, for compliance (Auditing)
Append-only eventsNever update; insert a new row per event; derive the stateLedgers, stock movements, payments
Soft deleteA deleted_at columnUndelete, but not a full history (Soft Delete)

MySQL has no built-in system-versioned (temporal) tables like some other databases. The patterns above are how it is done.

An append-only ledger

For money and stock, the strongest pattern is to never update a balance, only to add movements, and to compute the balance from them:

query.sql
SQL
CREATE TABLE stock_movements (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    product_id INT NOT NULL,
    change_qty INT NOT NULL,
    reason VARCHAR(20) NOT NULL,
    moved_at DATETIME NOT NULL
);
INSERT INTO stock_movements (product_id, change_qty, reason, moved_at) VALUES
(1, 100, 'purchase', '2026-02-01 09:00:00'),
(1, -3, 'sale', '2026-02-02 11:15:00'),
(1, -2, 'sale', '2026-02-03 15:40:00'),
(1, 1, 'return', '2026-02-04 10:05:00');

SELECT product_id, SUM(change_qty) AS in_stock FROM stock_movements GROUP BY product_id;
SELECT moved_at, reason, change_qty, SUM(change_qty) OVER (ORDER BY moved_at) AS running_stock FROM stock_movements WHERE product_id = 1 ORDER BY moved_at;
Output
OUTPUT
+------------+----------+
| product_id | in_stock |
+------------+----------+
|          1 |       96 |
+------------+----------+
1 row in set

+---------------------+----------+------------+---------------+
| moved_at            | reason   | change_qty | running_stock |
+---------------------+----------+------------+---------------+
| 2026-02-01 09:00:00 | purchase |        100 |           100 |
| 2026-02-02 11:15:00 | sale     |         -3 |            97 |
| 2026-02-03 15:40:00 | sale     |         -2 |            95 |
| 2026-02-04 10:05:00 | return   |          1 |            96 |
+---------------------+----------+------------+---------------+
4 rows in set

Every number can be explained by the rows that made it. A correction is a new row (a negative one), not an edit.

lightbulb
Half-open periods
Use “from (inclusive) to (exclusive)” for periods. With BETWEEN-style closed ranges, the same instant belongs to two periods, and every boundary needs a special case.