MySQL Primary Key
A primary key (PK) uniquely identifies every row in a table. It is the backbone of relational data integrity: it prevents duplicate records, provides the fastest possible row lookup, and serves as the anchor point for foreign keys from other tables. Choosing the right primary key strategy is one of the most consequential decisions in database design.
What a Primary Key Does
A primary key enforces two constraints simultaneously:
- Uniqueness — no two rows can have the same PK value.
- NOT NULL — every row must have a PK value.
In MySQL's InnoDB engine, the primary key is also the clustered index: the physical row data is stored sorted by the PK value. This means PK lookups are the absolute fastest queries possible against any InnoDB table.
-- Inline definition (table-level constraint) CREATE TABLE users ( id INT NOT NULL AUTO_INCREMENT, email VARCHAR(255) NOT NULL, name VARCHAR(100) NOT NULL, PRIMARY KEY (id) ); -- Shorthand (column-level constraint) CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255) NOT NULL, name VARCHAR(100) NOT NULL );
Single-Column vs Composite Primary Key
Single-column PKs are the default for most entity tables. An auto-increment integer or a UUID uniquely identifies each row.
Composite PKs span two or more columns. Use them for junction tables (many-to-many relationships) where the combination of columns is naturally unique and a surrogate ID would add no useful meaning.
-- Single-column PK (most entity tables) CREATE TABLE products ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(200) NOT NULL, price DECIMAL(10,2) NOT NULL ); -- Composite PK (many-to-many junction table) CREATE TABLE user_roles ( user_id INT NOT NULL, role_id INT NOT NULL, granted_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, role_id) ); -- Another composite PK example: order line items CREATE TABLE order_items ( order_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, unit_price DECIMAL(10, 2) NOT NULL, PRIMARY KEY (order_id, product_id) );
Clustered Index Deep-Dive
InnoDB stores table data in a B-tree sorted by the primary key — this is called the clustered index. The row data itself lives inside the B-tree leaf nodes, not in a separate heap file.
Key consequences:
- Range scans on the PK are extremely fast — matching rows are physically adjacent on disk.
- Secondary index lookups require two B-tree traversals: first the secondary index to find the PK value, then the clustered index to fetch the full row.
- Large PKs inflate every secondary index — each secondary index entry stores a copy of the PK.
- Insert order matters — sequential PKs append to the right edge of the B-tree, whereas random PKs (like UUID v4) insert into random positions, causing frequent page splits.
-- Range scan on PK: sequential disk reads, very fast SELECT * FROM orders WHERE id BETWEEN 5000 AND 5100; -- Secondary index lookup: two B-tree traversals -- 1. Find email in the secondary index -> get the PK value -- 2. Use the PK to fetch the full row from the clustered index SELECT * FROM users WHERE email = 'alice@example.com'; -- EXPLAIN shows the difference EXPLAIN SELECT * FROM orders WHERE id = 42; -- type: const -- single PK lookup, best possible EXPLAIN SELECT * FROM users WHERE email = 'alice@example.com'; -- type: ref -- secondary index ref + clustered index lookup
Sequential vs Random PKs — The Fragmentation Problem
InnoDB B-tree pages fill up over time. When a page is full and a new row is inserted:
- Sequential inserts (AUTO_INCREMENT): the new row goes to the rightmost page edge. InnoDB just appends — no reorganization needed. Pages fill to ~95% capacity.
- Random inserts (UUID v4): the new row may land anywhere in the tree. If the target page is full, InnoDB must split it in two and redistribute rows. Over time this causes many half-full pages (high fragmentation) and heavy random I/O.
-- AUTO_INCREMENT: sequential, low fragmentation
CREATE TABLE events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
event_type VARCHAR(100) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- UUID v4 (random): high fragmentation at scale
CREATE TABLE events_uuid (
id CHAR(36) NOT NULL DEFAULT (UUID()),
event_type VARCHAR(100) NOT NULL,
PRIMARY KEY (id)
);
-- UUID v7 / ordered UUID (MySQL 8.0): time-sortable, low fragmentation
-- MySQL built-in: swap flag=1 reorders time fields for locality
CREATE TABLE events_ordered_uuid (
id BINARY(16) NOT NULL DEFAULT (UUID_TO_BIN(UUID(), 1)),
event_type VARCHAR(100) NOT NULL,
PRIMARY KEY (id)
);
INSERT INTO events_ordered_uuid (event_type)
VALUES ('page_view');
SELECT BIN_TO_UUID(id, 1) AS id, event_type
FROM events_ordered_uuid;UUID_TO_BIN(UUID(), 1) (swap=1 reorders the time component) or a ULID library to generate time-ordered unique IDs that preserve insert locality.UUID v7 and ULID as Modern Alternatives
UUID v7 (RFC 9562, 2024) encodes a millisecond timestamp in the most significant bits,
making it time-sortable. MySQL 8.4+ natively generates UUID v7 via UUID_TO_BIN tricks;
many application frameworks generate them directly.
ULID (Universally Unique Lexicographically Sortable Identifier) is a 128-bit value with a 48-bit millisecond timestamp prefix. Like UUID v7, it sorts chronologically.
Both options give you the distribution benefits of a UUID (no central sequence, safe for distributed inserts) while preserving sequential insert order for the clustered index.
-- MySQL 8.0 ordered UUID workaround (swap=1 moves timestamp to front) INSERT INTO events (id, event_type) VALUES (UUID_TO_BIN(UUID(), 1), 'login'); -- Check whether inserts are ordered by comparing raw hex values SELECT HEX(id), event_type, created_at FROM events ORDER BY id LIMIT 5; -- The hex values should increase monotonically if using swap=1
Surrogate vs Natural Key
A surrogate key (e.g., auto-increment integer) has no business meaning — it exists purely as a unique identifier. A natural key uses real business data (e.g., email, ISBN, country code).
Factor | Surrogate Key | Natural Key |
|---|---|---|
Stability | Never changes | May change (email updates, rebranding) |
Size | Small (4-8 bytes) | May be large (email = up to 255 bytes) |
Meaningfulness | No business meaning | Self-documenting in raw data |
FK joins | Compact and fast | Large FK copies slow joins and inflate indexes |
Debugging | Opaque (what is id=42?) | Readable without a lookup |
Uniqueness guarantee | Guaranteed by DB sequence | Must validate uniqueness at app layer |
Multi-system sync | Conflicts likely without care | Globally unique if key is stable |
For most tables a surrogate integer key is the practical choice. Natural keys work best for reference/lookup tables where the identifier is truly immutable and globally meaningful (e.g., ISO country codes, currency codes, standard units).
Altering a Primary Key on a Large Table
-- Add a primary key to a table that has none ALTER TABLE legacy_table ADD PRIMARY KEY (id); -- Drop and replace the primary key ALTER TABLE legacy_table DROP PRIMARY KEY; ALTER TABLE legacy_table ADD PRIMARY KEY (new_id_col); -- Add AUTO_INCREMENT to an existing PK column ALTER TABLE legacy_table MODIFY COLUMN id INT NOT NULL AUTO_INCREMENT;
PRIMARY KEY and Replication
MySQL replication — and especially row-based replication — depends heavily on primary keys:
- Without a PK, row-based replication is very slow: MySQL must compare all column values to identify which row on the replica to update or delete.
- MySQL 8.0 emits a warning when you create a table without a PK: "Table does not have a primary key and row-based replication is in use."
- Group Replication (MGR) and NDB Cluster require every table to have a primary key.
-- Check for tables without a primary key in the current database SELECT t.table_name FROM information_schema.tables t LEFT JOIN information_schema.table_constraints c ON t.table_name = c.table_name AND t.table_schema = c.table_schema AND c.constraint_type = 'PRIMARY KEY' WHERE t.table_schema = DATABASE() AND t.table_type = 'BASE TABLE' AND c.constraint_name IS NULL;
PRIMARY KEY vs UNIQUE Constraint
Feature | PRIMARY KEY | UNIQUE |
|---|---|---|
NULL allowed | No — always NOT NULL | Yes — one NULL per column (multiple allowed in MySQL) |
Per table | Exactly one | Multiple allowed |
Clustered index | Yes (InnoDB) | No — secondary index only |
FK target | Most common target | Can also be a FK target |
Implicit NOT NULL | Yes | No — must add NOT NULL explicitly |
Practical Schema Examples
-- 1. High-volume OLTP table: INT AUTO_INCREMENT PK -- Low storage per row, sequential writes, fast range scans CREATE TABLE page_views ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, page_slug VARCHAR(200) NOT NULL, user_id INT UNSIGNED, viewed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_page_viewed (page_slug, viewed_at) ); -- 2. Distributed system / microservice: ordered UUID PK -- ID generated at application tier, no central sequence needed CREATE TABLE audit_events ( id BINARY(16) NOT NULL DEFAULT (UUID_TO_BIN(UUID(), 1)), service VARCHAR(50) NOT NULL, action VARCHAR(100) NOT NULL, occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (id) ); -- 3. Reference table: natural key is appropriate -- Country codes are stable, short, and globally meaningful CREATE TABLE countries ( code CHAR(2) NOT NULL, -- ISO 3166-1 alpha-2 name VARCHAR(100) NOT NULL, PRIMARY KEY (code) ); -- 4. Junction table: composite PK, no surrogate needed CREATE TABLE product_tags ( product_id INT UNSIGNED NOT NULL, tag_id INT UNSIGNED NOT NULL, tagged_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (product_id, tag_id), INDEX idx_tag (tag_id) -- secondary index for reverse lookup );
PRIMARY KEY and InnoDB Storage Details
Understanding how InnoDB stores data around the primary key helps you write faster queries and design better schemas.
Clustered index structure: InnoDB stores all row data inside the PK B-tree leaf pages. There is no separate "heap" file for row data (unlike MyISAM). This means:
- The PK B-tree IS the table.
- A full table scan is actually a full clustered index scan.
- Rows that fit on fewer B-tree pages (narrow rows, sequential PK) need fewer disk reads.
Secondary index internals: Every secondary index leaf node stores the indexed column(s) plus the full primary key value. This is how InnoDB finds the actual row after an index lookup — it takes the PK from the secondary index and does a second B-tree lookup on the clustered index. This two-step lookup is called a "bookmark lookup" or "clustered index lookup".
-- Illustrate clustered index lookup cost -- Secondary index on email -> stores (email, id) in the index leaf -- Query: SELECT name FROM users WHERE email = 'alice@example.com' -- Step 1: seek email index -> find id=42 -- Step 2: seek clustered index using id=42 -> find name -- A covering index eliminates step 2 by including name in the secondary index ALTER TABLE users ADD INDEX idx_email_name (email, name); -- Now the query is answered entirely from the secondary index (no clustered lookup) EXPLAIN SELECT name FROM users WHERE email = 'alice@example.com'; -- Extra: 'Using index' (covering index -- step 2 eliminated)
AUTO_INCREMENT Internals and Gaps
-- InnoDB stores the AUTO_INCREMENT counter in memory (not on disk in older versions) -- After a server restart, the counter is recovered from MAX(id) -- This means gaps can appear after rollbacks or inserts that fail -- Rolled-back INSERT still consumes the AUTO_INCREMENT value START TRANSACTION; INSERT INTO orders (customer_id, total) VALUES (1, 100.00); SELECT LAST_INSERT_ID(); -- e.g. returns 1001 ROLLBACK; -- Next successful INSERT will get 1002, not 1001 (1001 is permanently "lost") -- This is by design: InnoDB cannot reclaim rolled-back IDs -- Application code must NEVER assume IDs are consecutive -- MySQL 8.0 change: AUTO_INCREMENT counter is now persisted to the redo log -- so the counter survives a server crash/restart correctly SHOW VARIABLES LIKE 'innodb_autoinc_lock_mode'; -- 2 = interleaved (best performance, default in 8.0 with row-based replication)
Checking PK Usage and Table Structure
-- Inspect the primary key of any table SHOW INDEX FROM orders WHERE Key_name = 'PRIMARY'; -- Get PK column names from information_schema SELECT COLUMN_NAME, ORDINAL_POSITION, DATA_TYPE FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'orders' AND CONSTRAINT_NAME = 'PRIMARY'; -- Show size consumed by PK vs total table size SELECT table_name, ROUND(data_length / 1024 / 1024, 2) AS data_mb, ROUND(index_length / 1024 / 1024, 2) AS index_mb, ROUND((data_length + index_length) / 1024 / 1024, 2) AS total_mb FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY total_mb DESC;
Primary Key Strategy Decision Guide
Scenario | Recommended PK | Reason |
|---|---|---|
Single-server OLTP table | BIGINT UNSIGNED AUTO_INCREMENT | Sequential inserts, minimal index size, no coordination needed |
Distributed / microservices | BINARY(16) ordered UUID (swap=1) or ULID | No central sequence, time-ordered for InnoDB locality |
Many-to-many junction table | Composite PK (col_a, col_b) | Natural uniqueness, no surrogate ID adds value |
Reference / lookup table | Natural key (e.g. ISO code) | Stable, short, self-documenting |
Event / log table (append-only) | BIGINT AUTO_INCREMENT or ordered UUID | Sequential writes critical for append performance |
Shared across multiple services | UUID v7 or ULID | Globally unique without coordination |
Choosing the Right Integer Size for AUTO_INCREMENT
-- INT UNSIGNED: 0 to 4,294,967,295 (~4.3 billion) -- Suitable for tables expected to stay under 2 billion rows -- BIGINT UNSIGNED: 0 to 18,446,744,073,709,551,615 (~18 quintillion) -- Safe for virtually any table -- MEDIUMINT UNSIGNED: 0 to 16,777,215 (~16 million) -- Good for small lookup/reference tables to save storage -- Check current max id and headroom SELECT MAX(id) AS current_max, 4294967295 - MAX(id) AS int_headroom, CONCAT(ROUND((MAX(id) / 4294967295) * 100, 2), '%') AS int_pct_used FROM orders; -- If approaching INT_MAX, migrate to BIGINT before running out -- Online migration (no downtime): ALTER TABLE orders MODIFY COLUMN id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT; -- In MySQL 8.0+ this uses an instant ALTER for many column type changes
Common Primary Key Mistakes
Mistake | Problem | Fix |
|---|---|---|
Using VARCHAR as PK | Large key inflates all secondary indexes; string comparison is slower than integer | Use INT/BIGINT AUTO_INCREMENT surrogate key |
Using UUID v4 without ordering | Random inserts cause B-tree page splits, high fragmentation | Use UUID_TO_BIN(UUID(),1) or UUID v7 / ULID |
No PK at all | InnoDB uses a hidden 6-byte row ID (not accessible), row-based replication is very slow | Always define an explicit PRIMARY KEY |
INT that will overflow | Insert fails with "Duplicate entry for key PRIMARY" when INT_MAX is reached | Use BIGINT UNSIGNED from the start |
Composite PK in parent tables | FK copies the full composite PK into every child table, spreading complexity | Add a surrogate INT/BIGINT PK to parent, use it as FK |
PK on mutable business data | Updates to PK cascade to all FK references, or fail if ON UPDATE RESTRICT | Use stable surrogate key; store business key as UNIQUE NOT NULL |
Design Guidelines
Choose narrow PKs — every secondary index stores a copy of the PK. A 4-byte INT is far better than a 36-byte UUID string for high-volume tables.
Prefer monotonically increasing PKs — sequential IDs (AUTO_INCREMENT, time-ordered UUIDs) minimize index fragmentation in InnoDB.
Use BIGINT UNSIGNED for high-traffic tables — INT maxes out at ~2.1 billion rows which large applications can reach.
Avoid composite PKs in parent tables — they propagate complexity into every child table's FK column.
Use surrogate keys for most entity tables — natural keys mutate (emails change, companies rebrand).
Never use sensitive data (SSN, email, phone) as a PK — they can change and expose PII in join conditions and logs.
Always define a PK on every table, even in development — tables without PKs perform poorly under row-based replication.
Do not rely on PK values being consecutive — rollbacks, inserts that fail, and server restarts all create gaps.