MySQLPrimary Keys

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:

  1. Uniqueness — no two rows can have the same PK value.
  2. 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.

SQL
-- 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.

SQL
-- 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)
);
Note
With a composite PK, the leftmost column is the leading edge of the clustered index. Queries filtering only by the second column (without the first) require a separate secondary index.
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.

SQL
-- 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.

SQL
-- 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;
Warning
Standard UUID v4 PKs cause write amplification in InnoDB because each insert lands at a random B-tree position, triggering frequent page splits. Use 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.

SQL
-- 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

SQL
-- 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;
Warning
Dropping or changing a primary key on a large InnoDB table rebuilds the entire clustered index — effectively rewriting the whole table. On a multi-million-row table this can take minutes or hours. Use an online DDL tool (pt-online-schema-change or gh-ost) in production to avoid locking readers and writers.
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.

SQL
-- 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

SQL
-- 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".

SQL
-- 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

SQL
-- 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)
Note
Gaps in AUTO_INCREMENT sequences are normal and expected. Do not design business logic that assumes sequential IDs. If you need a true gapless counter, implement it with a separate sequence table and pessimistic locking — but be aware of the performance cost.
Checking PK Usage and Table Structure

SQL
-- 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

SQL
-- 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.