Database Primary Key
Choosing a database primary key between big integers, ordered UUID, and nanoid.
TLDR: Use Both Integer (as PK) and UUID/ULID.
The Ultimate Database Key Solution
When it comes to database design, one of the most critical decisions is choosing the right primary key (PK) strategy. For years, there’s been an ongoing debate between natural and surrogate keys, with surrogate keys generally emerging as the superior choice because they are more flexible and disconnected from real-world data. But the debate doesn’t stop there; surrogate keys can take multiple forms, commonly seen as simple integers or UUID/ULID.
But there's no need to limit yourself to one or the other. Instead, let's leverage the best of both worlds: use both an integer (auto-increment) and a UUID/ULID.
Why Choose Both an Integer and UUID/ULID?
Big Integers | Ordered UUIDv4 | nanoid | |
---|---|---|---|
Example | 18446744073709551615 | 70E2E8DE-500E-4630-B3CB-166131D35C21 | FYaebyAFTGca |
Data Type | BIGINT | BINARY(16) | CHAR(12) |
Size | 8 bytes | 16 bytes | 12 bytes |
Sequential | Yes | Yes | No |
Max Records | 2⁶⁴ | 2¹²² | 64¹² (about 2⁷²) |
Expose timestamp | No | Yes | No |
Expose total records | Yes | No | No |
Generation at | Database | App | App |
1. Integer as the Primary Key
The idea is simple: use an integer as your primary key. Here’s why:
- Compact and performant: Integers, especially BIGINT, are compact and fast as they are only 8 bytes long. As highly sequential keys, they also optimize database indexing, which is crucial for performance.
- Human readability: Numeric IDs are much easier to read, debug, and refer to in queries. You can simply remember or double-click and copy an integer like "1234" instead of deciphering long, complex strings like "70E2E8DE-500E-4630-B3CB-166131D35C21."
- Suitable for internal joins: Running joins across multiple tables using integers is incredibly efficient and straightforward.
2. UUID/ULID for External Exposure (e.g., URLs)
In addition to the integer primary key, you should store a UUID or ULID in a separate column. Here’s why:
- Avoid exposing internal structure: Using a UUID/ULID in APIs and URLs hides the underlying database structure. It prevents users from easily guessing the next record or inferring the total count of records.
- Avoid security risks: By using UUID/ULID in URLs, you drastically reduce the possibility of people figuring out confidential information (e.g., user counts) or guessing and accessing adjacent records.
- Uniqueness across systems: UUIDs and ULIDs are useful in distributed systems, ensuring globally unique identifiers without the need for worrying about synchronization between your databases.
Composite Design: The Best of Both Worlds
Here’s the proposed structure:
- Primary key column: An auto-incrementing integer for internal usage and table joins.
- UUID/ULID column: Used in URLs, APIs, and external exposure. This column isn’t the primary key but serves as a public identifier of records.
In this way, you achieve both database optimization and external security and flexibility.
Example Table Structure
CREATE TABLE example_table (
id BIGINT AUTO_INCREMENT PRIMARY KEY, -- Internal Primary Key
uuid BINARY(16) NOT NULL UNIQUE, -- External Identifier (UUID/ULID)
...
);
The Conclusion
With this dual-identifier strategy, you cover all your bases:
- Use an integer primary key for internal indexing and database performance.
- Add a UUID/ULID column for exposing records via URLs or APIs without compromising internal structure.
Let the cloud database providers (such as Amazon RDS, PlanetScale, etc.) handle the performance concerns—you’ll have the structure that perfectly balances performance with security, scalability, and flexibility.
Case closed.