#CREATE TABLE and ALTER TABLE
AddedA table definition is a contract. Every column says what kind of value it accepts, whether it may be missing, and what to use when nothing is given; every constraint says which combinations of rows are allowed. Once the contract is in place, every application, script and person that writes to the table is held to it — the database refuses bad data instead of trusting each piece of code to check.
Think of CREATE TABLE as printing a paper form with labelled boxes ("date of birth: DD/MM/YYYY, required") and ALTER TABLE as reprinting the form after thousands of copies have already been filled in. Reprinting is easy when the change only adds an optional box; it's expensive when every existing copy has to be rewritten — that's what Online schema changes is about.
A CREATE TABLE has three kinds of parts: column definitions (name, type, NULL/NOT NULL, DEFAULT, AUTO_INCREMENT), indexes and keys (PRIMARY KEY, UNIQUE, INDEX) and constraints (FOREIGN KEY, CHECK). Here is a reviews table for the site's sample schema, using all three:
CREATE TABLE reviews (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_id INT NOT NULL,
customer_id INT NOT NULL,
rating TINYINT NOT NULL,
body TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_reviews_product FOREIGN KEY (product_id) REFERENCES products(id),
CONSTRAINT fk_reviews_customer FOREIGN KEY (customer_id) REFERENCES customers(id),
CONSTRAINT chk_reviews_rating CHECK (rating BETWEEN 1 AND 5)
);
DESCRIBE reviews;
INSERT INTO reviews (product_id, customer_id, rating, body) VALUES (1, 1, 5, 'Light and fast'), (3, 2, 4, NULL);
SELECT id, product_id, customer_id, rating, body FROM reviews;+-------------+--------------+------+-----+-------------------+-------------------+
| Field | Type | Null | Key | Default | Extra |
+-------------+--------------+------+-----+-------------------+-------------------+
| id | int unsigned | NO | PRI | NULL | auto_increment |
| product_id | int | NO | MUL | NULL | |
| customer_id | int | NO | MUL | NULL | |
| rating | tinyint | NO | | NULL | |
| body | text | YES | | NULL | |
| created_at | datetime | NO | | CURRENT_TIMESTAMP | DEFAULT_GENERATED |
+-------------+--------------+------+-----+-------------------+-------------------+
+----+------------+-------------+--------+----------------+
| id | product_id | customer_id | rating | body |
+----+------------+-------------+--------+----------------+
| 1 | 1 | 1 | 5 | Light and fast |
| 2 | 3 | 2 | 4 | NULL |
+----+------------+-------------+--------+----------------+id INT UNSIGNED AUTO_INCREMENT PRIMARY KEYmakesidthe primary key — in InnoDB that also makes it the key the rows are physically stored by — and lets MySQL hand out1, 2, 3…when the insert leaves it out.DESCRIBEshows that asPRIandauto_increment.NOT NULLonproduct_id,customer_idandratingmeans a review without them is rejected.bodyhas noNOT NULL, so it's nullable — the second review storesNULL, which means "no text", not an empty string.DEFAULT CURRENT_TIMESTAMPfillscreated_atwhen the insert doesn't mention it;DESCRIBEmarks that asDEFAULT_GENERATEDbecause the default is an expression, not a constant.- The two
FOREIGN KEYconstraints say everyproduct_idmust exist inproducts.idand everycustomer_idincustomers.id. InnoDB needs an index on each to check them quickly, so it created one automatically — that's theMUL(non-unique index) in theKeycolumn. CHECK (rating BETWEEN 1 AND 5)is enforced on every insert and update (since MySQL 8.0.16). Naming constraints withCONSTRAINT namepays off later: error messages quote the name, andALTER TABLE … DROP CONSTRAINTneeds it.- Both inserts satisfy the contract, so both rows land and get ids
1and2.
ALTER TABLE changes an existing definition. Several changes can go in one statement, which matters because each ALTER that rebuilds the table pays the cost once per statement:
ALTER TABLE reviews
ADD COLUMN title VARCHAR(120) NULL AFTER rating,
RENAME COLUMN body TO comment,
MODIFY rating TINYINT UNSIGNED NOT NULL,
ADD CONSTRAINT uq_reviews_product_customer UNIQUE (product_id, customer_id);
SHOW CREATE TABLE reviews;
SELECT * FROM reviews;*************************** 1. row ***************************
Table: reviews
Create Table: CREATE TABLE `reviews` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`product_id` int NOT NULL,
`customer_id` int NOT NULL,
`rating` tinyint unsigned NOT NULL,
`title` varchar(120) DEFAULT NULL,
`comment` text,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_reviews_product_customer` (`product_id`,`customer_id`),
KEY `fk_reviews_customer` (`customer_id`),
CONSTRAINT `fk_reviews_customer` FOREIGN KEY (`customer_id`) REFERENCES `customers` (`id`),
CONSTRAINT `fk_reviews_product` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`),
CONSTRAINT `chk_reviews_rating` CHECK ((`rating` between 1 and 5))
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
+----+------------+-------------+--------+-------+----------------+---------------------+
| id | product_id | customer_id | rating | title | comment | created_at |
+----+------------+-------------+--------+-------+----------------+---------------------+
| 1 | 1 | 1 | 5 | NULL | Light and fast | 2026-10-16 15:23:17 |
| 2 | 3 | 2 | 4 | NULL | NULL | 2026-10-16 15:23:17 |
+----+------------+-------------+--------+-------+----------------+---------------------+ADD COLUMN title … AFTER ratingadds a nullable column in a chosen position; existing rows getNULLbecause there's no default.RENAME COLUMN body TO comment(MySQL 8.0+) renames without restating the type. On 5.7 you had to writeCHANGE body comment TEXTand repeat the whole definition — forget an attribute and it's silently dropped.MODIFY rating TINYINT UNSIGNED NOT NULLchanges the type.MODIFYreplaces the whole column definition, so anything you don't repeat (aDEFAULT, aCOMMENT,NOT NULL) is lost — the classicMODIFYaccident.ADD CONSTRAINT … UNIQUE (product_id, customer_id)means one review per customer per product.- Look at the keys in
SHOW CREATE TABLE: the automaticfk_reviews_productindex is gone. The new unique index starts withproduct_id, so it can serve the foreign key too, and InnoDB dropped the now-redundant one. The docs warn about exactly this: the automatic index "might be silently dropped later". SHOW CREATE TABLEis the one to trust for "what is this table really?" — it shows the engine, the character set, the collation and the nextAUTO_INCREMENTvalue (3), none of whichDESCRIBEshows.
Here are the indexes from information_schema.statistics, first right after CREATE TABLE, then after the ALTER:
SELECT index_name, column_name, seq_in_index, non_unique
FROM information_schema.statistics
WHERE table_schema = 'ch07' AND table_name = 'reviews'
ORDER BY index_name, seq_in_index;+---------------------+-------------+--------------+------------+
| INDEX_NAME | COLUMN_NAME | SEQ_IN_INDEX | NON_UNIQUE |
+---------------------+-------------+--------------+------------+
| fk_reviews_customer | customer_id | 1 | 1 |
| fk_reviews_product | product_id | 1 | 1 |
| PRIMARY | id | 1 | 0 |
+---------------------+-------------+--------------+------------+
+-----------------------------+-------------+--------------+------------+
| INDEX_NAME | COLUMN_NAME | SEQ_IN_INDEX | NON_UNIQUE |
+-----------------------------+-------------+--------------+------------+
| fk_reviews_customer | customer_id | 1 | 1 |
| PRIMARY | id | 1 | 0 |
| uq_reviews_product_customer | product_id | 1 | 0 |
| uq_reviews_product_customer | customer_id | 2 | 0 |
+-----------------------------+-------------+--------------+------------+- Before: one index per foreign key (
fk_reviews_customer,fk_reviews_product) plusPRIMARY. The automatic indexes take the constraint's name. - After:
fk_reviews_producthas disappeared.uq_reviews_product_customerhasproduct_idat position1, which is all a foreign key check onproduct_idneeds ("find child rows with this product_id" is a leftmost-prefix lookup). fk_reviews_customerstays, because in the unique indexcustomer_idis only the second column — useless for "find rows by customer_id" on its own (see Composite indexes and the leftmost-prefix rule).- The practical consequence: the unique index is now load-bearing. Try to drop it and MySQL refuses:
ERROR 1553 (HY000): Cannot drop index 'uq_reviews_product_customer': needed in a foreign key constraint. Add a plain index onproduct_idfirst, then drop the unique one.
Three shortcuts behave differently in ways that bite:
CREATE TABLE IF NOT EXISTS reviews (id INT PRIMARY KEY);
CREATE TABLE reviews_copy LIKE reviews;
CREATE TABLE reviews_snapshot AS SELECT id, rating, created_at FROM reviews;
SELECT table_name, column_name, column_key, extra FROM information_schema.columns
WHERE table_schema = 'ch07' AND table_name IN ('reviews_copy', 'reviews_snapshot') AND column_name = 'id';
SELECT COUNT(*) AS rows_in_copy FROM reviews_copy;
SELECT COUNT(*) AS rows_in_snapshot FROM reviews_snapshot;
DROP TABLE reviews_copy, reviews_snapshot;
DROP TABLE reviews_nope;
DROP TABLE IF EXISTS reviews_nope;Note (Code 1050): Table 'reviews' already exists
+------------------+-------------+------------+----------------+
| TABLE_NAME | COLUMN_NAME | COLUMN_KEY | EXTRA |
+------------------+-------------+------------+----------------+
| reviews_copy | id | PRI | auto_increment |
| reviews_snapshot | id | | |
+------------------+-------------+------------+----------------+
+--------------+
| rows_in_copy |
+--------------+
| 0 |
+--------------+
+------------------+
| rows_in_snapshot |
+------------------+
| 2 |
+------------------+
ERROR 1051 (42S02): Unknown table 'ch07.reviews_nope'
Note (Code 1051): Unknown table 'ch07.reviews_nope'CREATE TABLE IF NOT EXISTSwith a different definition does nothing and only leaves a note. It never compares definitions — a migration that relies on it can leave an old table in place without complaint.CREATE TABLE … LIKEcopies the structure — columns, indexes,AUTO_INCREMENT,CHECKconstraints — but no rows and no foreign keys.CREATE TABLE … AS SELECTcopies the rows but only plain column types:idlost its primary key andauto_increment. It's for snapshots and reports, not for cloning a table.DROP TABLEon a missing table is an error;DROP TABLE IF EXISTSturns it into a note, which is why migration and test scripts use it.
Adding NOT NULL to existing data has two very different outcomes:
ALTER TABLE reviews MODIFY comment TEXT NOT NULL;
ALTER TABLE reviews ADD COLUMN helpful_votes INT NOT NULL;
SELECT id, comment, helpful_votes FROM reviews;ERROR 1138 (22004): Invalid use of NULL value
+----+----------------+---------------+
| id | comment | helpful_votes |
+----+----------------+---------------+
| 1 | Light and fast | 0 |
| 2 | NULL | 0 |
+----+----------------+---------------+- Review
2has aNULLcomment, so changingcommenttoNOT NULLfails withERROR 1138— strict mode won't invent a value for existing data. Fix the data first (UPDATE reviews SET comment = '' WHERE comment IS NULL), then alter. - A new
NOT NULLcolumn without aDEFAULTis allowed: existing rows get the type's implicit default —0for numbers,''for strings, the "zero" value for dates. - That implicit
0may be a lie ("this review has 0 helpful votes" when really nobody counted). Give new columns an explicitDEFAULTthat means what you intend.