MENU
Defining Tables
A table definition is a contract that MySQL enforces on every write, whichever client sends it: column types, NOT NULL, unique keys, CHECK expressions and foreign keys. This section builds and alters the bookshop schema below, which sample-db.sql loads and the rest of the chapter queries. Section 3.3 chose its types; Section 3.10 explains why it has these six tables, and Section 3.11 indexes them.
Figure 3.3: The bookshop schema: six tables, with arrows from each
foreign key to the key it references3.4.1 CREATE TABLE and Column Definitions
Each column in CREATE TABLE is a name, a type (Section 3.3) and attributes such as NOT NULL, DEFAULT, AUTO_INCREMENT, UNIQUE, COMMENT or INVISIBLE; multi-column keys follow the columns. Since MySQL 8.0.13 a default may be an expression in parentheses:CREATE TABLE coupons (
code CHAR(8) NOT NULL DEFAULT (UPPER(LEFT(REPLACE(UUID(), '-', ''), 8))),
valid_until DATE NOT NULL DEFAULT (CURRENT_DATE + INTERVAL 30 DAY),
PRIMARY KEY (code)
);
SHOW CREATE TABLE customers\G
*************************** 1. row ***************************
Table: customers
Create Table: CREATE TABLE `customers` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`email` varchar(255) NOT NULL,
`name` varchar(100) NOT NULL,
`country` char(2) NOT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`)
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
3.4.2 Primary Keys, AUTO_INCREMENT, and Generated Invisible Primary Keys
InnoDB orders each table by its primary key (Section 3.11.2) and stores that key in every secondary index, so keep it short and stable: INT UNSIGNED AUTO_INCREMENT for entities, the two foreign keys for a link table such as order_items (natural keys: Section 3.10.8).A table without a primary key gets a hidden row id you cannot query or replicate by. Since MySQL 8.0.30, sql_generate_invisible_primary_key adds a real one instead, my_row_id, as an invisible column (MySQL 8.0.23): SELECT * and column-less INSERTs skip it.INSERT INTO customers (email, name, country) VALUES ('ivan@example.com', 'Ivan Petrov', 'BG');
INSERT INTO customers (email, name, country) VALUES ('ivan@example.com', 'Ivan P.', 'BG');
INSERT INTO customers (email, name, country) VALUES ('jo@example.com', 'Jo Park', 'KR');
SELECT id, email FROM customers WHERE id > 8;
SET SESSION sql_generate_invisible_primary_key = ON;
CREATE TABLE page_views (url VARCHAR(255) NOT NULL, viewed_at DATETIME NOT NULL);
INSERT INTO page_views VALUES ('/books/1', NOW()), ('/books/2', NOW());
ERROR 1062 (23000): Duplicate entry 'ivan@example.com' for key 'customers.email'
+----+------------------+
| id | email |
+----+------------------+
| 9 | ivan@example.com |
| 11 | jo@example.com |
+----+------------------+
3.4.3 UNIQUE, NOT NULL, and CHECK Constraints
NOT NULL makes a value mandatory, UNIQUE rejects duplicates but allows many NULLs, and CHECK (expr) rejects rows for which the expression is false (NULL passes). MySQL ignored CHECK until 8.0.16; it is now enforced on every insert and update.INSERT INTO reviews (product_id, customer_id, rating, created_at) VALUES (1, 2, 6, NOW());
INSERT INTO customers (email, name, country) VALUES (NULL, 'Nobody', 'US');
ALTER TABLE orders ADD COLUMN shipped_at DATETIME NULL,
ADD CONSTRAINT chk_shipped_after CHECK (shipped_at IS NULL OR shipped_at >= ordered_at);
UPDATE orders SET shipped_at = '2026-03-01 09:00:00' WHERE id = 1;
ALTER TABLE orders ADD CONSTRAINT chk_past CHECK (ordered_at <= NOW());
ERROR 3819 (HY000): Check constraint 'reviews_chk_1' is violated.
ERROR 1048 (23000): Column 'email' cannot be null
Query OK, 9 rows affected (0.124 sec)
Records: 9 Duplicates: 0 Warnings: 0
ERROR 3819 (HY000): Check constraint 'chk_shipped_after' is violated.
ERROR 3814 (HY000): An expression of a check constraint 'chk_past' contains disallowed
function: now.
3.4.4 Foreign Keys and Referential Actions
A foreign key requires a child value to exist in the parent: orders.customer_id must be a customers.id. The default action, NO ACTION (the same as RESTRICT), rejects a parent change while children exist; CASCADE deletes or updates the children, and SET NULL clears their column. The listing tests the default, then makes order lines die with their order and lets a subcategory outlive its parent:INSERT INTO orders (customer_id, ordered_at) VALUES (99, NOW());
DELETE FROM customers WHERE id = 1;
ALTER TABLE order_items
DROP FOREIGN KEY order_items_ibfk_1,
ADD CONSTRAINT fk_items_order FOREIGN KEY (order_id) REFERENCES orders (id)
ON DELETE CASCADE;
ALTER TABLE categories
DROP FOREIGN KEY categories_ibfk_1,
ADD CONSTRAINT fk_category_parent FOREIGN KEY (parent_id) REFERENCES categories (id)
ON DELETE SET NULL ON UPDATE CASCADE;
INSERT INTO categories (name, parent_id) VALUES ('Gifts', NULL), ('Posters', 6);
DELETE FROM orders WHERE id = 5;
DELETE FROM categories WHERE name = 'Gifts';
SELECT (SELECT COUNT(*) FROM order_items WHERE order_id = 5) AS items_left,
(SELECT parent_id FROM categories WHERE name = 'Posters') AS posters_parent;
ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails
(`shop`.`orders`, CONSTRAINT `orders_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES
`customers` (`id`))
ERROR 1451 (23000): Cannot delete or update a parent row: a foreign key constraint fails
(`shop`.`orders`, CONSTRAINT `orders_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES
`customers` (`id`))
+------------+----------------+
| items_left | posters_parent |
+------------+----------------+
| 0 | NULL |
+------------+----------------+
3.4.5 Table Options: Engine, Character Set, and Row Format
The options after the closing parenthesis apply to the whole table. Keep ENGINE=InnoDB, the default, for application data (Section 3.14.10 covers the others): a foreign key to a MyISAM table fails with error 1824, and a misspelled or MariaDB-only engine such as Aria fails with error 1286 instead of silently becoming InnoDB. DEFAULT CHARSET and COLLATE set the column defaults (Section 3.3.8). ROW_FORMAT=DYNAMIC, the default, moves long TEXT, BLOB and JSON values off-page behind a 20-byte pointer; COMPACT keeps a 768-byte prefix inline, REDUNDANT is the legacy format, and COMPRESSED is in Section 3.4.9. AUTO_INCREMENT = 1000 starts numbering higher, and COMMENT = '...' documents the table; information_schema.TABLES reports every option.3.4.6 ALTER TABLE and Online DDL
ALTER TABLE adds, drops, renames and modifies columns, indexes, constraints and options, several per statement. ALGORITHM decides how: INSTANT edits only the data dictionary (adding, dropping or renaming a column, or changing a default, since 8.0.29); INPLACE works inside the tablespace while other sessions keep writing (LOCK=NONE); COPY rebuilds the table and blocks writes. Name the one you expect, so the statement fails instead of silently copying a big table:CREATE TABLE order_events (id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_id INT UNSIGNED NOT NULL, event VARCHAR(20) NOT NULL, amount DECIMAL(8,2) NOT NULL);
SET SESSION cte_max_recursion_depth = 2000000;
INSERT INTO order_events (order_id, event, amount)
WITH RECURSIVE n (i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 2000000)
SELECT i % 9 + 1, ELT(i % 3 + 1, 'created', 'paid', 'shipped'), i % 500 / 10 FROM n;
ALTER TABLE order_events
ADD COLUMN channel VARCHAR(10) NOT NULL DEFAULT 'web', ALGORITHM=INSTANT;
SELECT TABLE_ID, TOTAL_ROW_VERSIONS FROM information_schema.INNODB_TABLES
WHERE NAME = 'shop/order_events';
ALTER TABLE order_events ADD COLUMN source VARCHAR(10) NOT NULL DEFAULT 'web', ALGORITHM=COPY;
SELECT TABLE_ID, TOTAL_ROW_VERSIONS FROM information_schema.INNODB_TABLES
WHERE NAME = 'shop/order_events';
ALTER TABLE order_events MODIFY amount DECIMAL(10,2) NOT NULL, ALGORITHM=INSTANT;
ALTER TABLE order_events ADD INDEX idx_order (order_id), ALGORITHM=INPLACE, LOCK=NONE;
Query OK, 0 rows affected (0.030 sec)
+----------+--------------------+
| TABLE_ID | TOTAL_ROW_VERSIONS |
+----------+--------------------+
| 1406 | 1 |
+----------+--------------------+
Query OK, 2000000 rows affected (8.903 sec)
+----------+--------------------+
| TABLE_ID | TOTAL_ROW_VERSIONS |
+----------+--------------------+
| 1421 | 0 |
+----------+--------------------+
ERROR 1846 (0A000): ALGORITHM=INSTANT is not supported. Reason: Need to rebuild the table to
change column type. Try ALGORITHM=COPY/INPLACE.
Query OK, 0 rows affected (3.407 sec)
3.4.7 RENAME, TRUNCATE, and DROP TABLE
RENAME TABLE a TO b, c TO d renames atomically, so a rebuilt copy can replace a table without any query finding it missing, and RENAME TABLE shop.a TO archive.a moves a table between databases.RENAME TABLE customers_archive TO customers_old, country_counts TO stats_by_country;
DROP TABLE customers;
DROP TABLE customers_old, no_such_table;
CREATE TABLE products_new LIKE products;
INSERT INTO products_new SELECT * FROM products;
RENAME TABLE products TO products_old, products_new TO products;
SELECT CONSTRAINT_NAME, REFERENCED_TABLE_NAME FROM information_schema.REFERENTIAL_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = 'shop' AND TABLE_NAME = 'order_items';
ERROR 3730 (HY000): Cannot drop table 'customers' referenced by a foreign key constraint
'reviews_ibfk_2' on table 'reviews'.
ERROR 1051 (42S02): Unknown table 'shop.no_such_table'
+--------------------+-----------------------+
| CONSTRAINT_NAME | REFERENCED_TABLE_NAME |
+--------------------+-----------------------+
| fk_items_order | orders |
| order_items_ibfk_2 | products_old |
+--------------------+-----------------------+
3.4.8 Generated Columns and Functional Indexes
A generated column, col type AS (expr), is computed from its row: VIRTUAL (the default) on every read, STORED on every write. Both can be indexed, the expression must be deterministic, and you cannot write to them.ALTER TABLE products
ADD COLUMN format VARCHAR(20) AS (attributes->>'$.format') VIRTUAL AFTER price,
ADD INDEX idx_format (format);
ALTER TABLE order_items
ADD COLUMN line_total DECIMAL(10,2) AS (quantity * unit_price) STORED;
CREATE INDEX idx_pages ON products ((CAST(attributes->>'$.pages' AS UNSIGNED)));
SELECT sku, format, price FROM products WHERE format = 'hardcover';
UPDATE order_items SET line_total = 0 WHERE order_id = 1;
+-----------+-----------+-------+
| sku | format | price |
+-----------+-----------+-------+
| BK-LAR-01 | hardcover | 49.00 |
+-----------+-----------+-------+
ERROR 3105 (HY000): The value specified for generated column 'line_total' in table
'order_items' is not allowed.
3.4.9 Tablespaces, Compression, and Table Encryption
Each table normally has its own file, /var/lib/mysql/shop/orders.ibd. A general tablespace (CREATE TABLESPACE shop_archive ADD DATAFILE 'shop_archive.ibd', then ALTER TABLE orders_2025 TABLESPACE shop_archive) holds several tables and belongs to the server: it survived DROP DATABASE in testing.ROW_FORMAT=COMPRESSED squeezes 16 KB pages into KEY_BLOCK_SIZE KB; COMPRESSION='zlib' or 'lz4' compresses pages on write and punches holes in the file. The innodb_file_format setting in old guides was removed in MySQL 8.0.CREATE TABLE log_plain (id INT AUTO_INCREMENT PRIMARY KEY, msg TEXT NOT NULL);
CREATE TABLE log_page LIKE log_plain; ALTER TABLE log_page COMPRESSION = 'zlib';
CREATE TABLE log_row LIKE log_plain;
ALTER TABLE log_row ROW_FORMAT = COMPRESSED KEY_BLOCK_SIZE = 8;
INSERT INTO log_plain (msg)
SELECT CONCAT('GET /books/', id, ' 200 ', REPEAT('Mozilla/5.0 (X11; Linux x86_64) ', 4))
FROM order_events LIMIT 300000;
INSERT INTO log_page (msg) SELECT msg FROM log_plain;
INSERT INTO log_row (msg) SELECT msg FROM log_plain;
SELECT NAME, FILE_SIZE, ALLOCATED_SIZE FROM information_schema.INNODB_TABLESPACES
WHERE NAME LIKE 'shop/log%';
+----------------+-----------+----------------+
| NAME | FILE_SIZE | ALLOCATED_SIZE |
+----------------+-----------+----------------+
| shop/log_plain | 67108864 | 67112960 |
| shop/log_page | 67108864 | 24535040 |
| shop/log_row | 31457280 | 31461376 |
+----------------+-----------+----------------+