Introduction
Choosing an identifier looks like a small decision until the application starts growing around it.
At first, an auto-incrementing integer feels enough. It is small, fast, easy to read, and every relational database understands it. Then the requirements start changing:
- you need to create records in different services without asking one database for the next ID
- you want public URLs that do not expose sequential database IDs
- you want IDs that sort roughly by creation time
- you need identifiers that can be generated before a row exists
- you want event IDs, request IDs, job IDs, or import IDs that work across systems
That is usually when UUIDs, ULIDs, and Sqids enter the conversation.
They are often discussed as if they solve the same problem, but they do not. They overlap in some places, but their mental models are different:
- UUIDs are standardized 128-bit identifiers designed for distributed uniqueness.
- ULIDs are 128-bit identifiers with a timestamp-first layout and a compact sortable string representation.
- Sqids are short, URL-safe IDs generated from non-negative numbers, usually existing integer primary keys.
In this article, let's do a practical deep dive into how they work, what trade-offs they bring, and when I would use each one in a real application. The examples will use PHP, but the ideas are language and framework agnostic.
The Problem We Are Actually Solving
Before choosing a format, we need to be clear about the job of the identifier.
An identifier can be used for different things:
- a database primary key
- a public route parameter
- an event ID
- a trace or correlation ID
- an idempotency key
- a temporary token
- a reference shown to users
Those jobs have different requirements.
For example, a database primary key should be compact, indexed efficiently, and stable forever. A public URL ID should be safe to expose and pleasant enough to copy. An event ID should be globally unique without central coordination. A security token should be unguessable and should usually not be decodable.
This is why the question should not be:
Which one is the best ID?
The better question is:
What properties does this identifier need in this part of the system?
Once we ask that, the comparison becomes much clearer.
The Quick Comparison
Let's start with the high-level view.
- UUIDv4: 36 characters, generated from randomness, not sortable by time, not decodable, and best used for distributed opaque IDs.
- UUIDv7: 36 characters, generated from timestamp plus randomness, sortable by time, partially decodable because the timestamp is part of the value, and best used for distributed time-ordered IDs.
- ULID: 26 characters, generated from timestamp plus randomness, lexicographically sortable, partially decodable because the timestamp is part of the value, and best used for compact sortable public IDs.
- Sqids: variable length, generated from numbers, not sortable by creation time by itself, decodable, and best used for public IDs generated from numeric input.
That comparison already tells us something important.
Sqids are generated IDs too, but they are not generated from the same source as UUIDs and ULIDs.
UUIDs and ULIDs generate standalone identifiers from time, randomness, or both. Sqids generate
deterministic, reversible IDs from numbers you provide. If your database row has an integer ID of
12345, Sqids can turn it into something like NkK9q. But the number is still represented by that
string. It is not encryption, it is not hashing, and it is not a security boundary.
That difference will come back several times throughout the article.
UUIDs: The Standard Workhorse
UUID stands for Universally Unique Identifier.
The modern UUID specification is RFC 9562, which replaced RFC 4122. A UUID is always 128 bits. The canonical text representation is the familiar 36-character hex-and-dash format:
f81d4fae-7dec-11d0-a765-00a0c91e6bf6
The string has five groups of hexadecimal characters separated by dashes:
8-4-4-4-12
Under the hood, the UUID also contains version and variant bits. The version tells you what kind of UUID it is.
The versions most application developers usually care about are:
- UUIDv4: random or pseudorandom
- UUIDv5: deterministic from a namespace and name using SHA-1
- UUIDv6: reordered time-based UUID for better database locality
- UUIDv7: Unix timestamp in milliseconds plus random data
For most modern application work, the practical choice is usually between UUIDv4 and UUIDv7.
UUIDv4
UUIDv4 is the classic random UUID.
It uses 122 random bits after reserving the required version and variant bits. That gives it a huge identifier space, which is why it became the default answer for distributed systems for many years.
In PHP, using ramsey/uuid, generating one looks like this:
use Ramsey\Uuid\Uuid;
$orderId = Uuid::uuid4()->toString();
echo $orderId;
// 8f2d7e76-7f41-4c43-9d67-9d2b4b2c8b98
You can also validate the string shape:
use Ramsey\Uuid\Uuid;
if (! Uuid::isValid($orderId)) {
throw new InvalidArgumentException('Invalid order ID.');
}
The main benefit of UUIDv4 is simplicity: generate random bytes, set the UUID bits, and you have an ID that can be created without talking to a database or central service.
That is excellent for:
- public entity IDs
- event IDs
- import IDs
- distributed writes
- client-generated IDs
- correlation IDs where sort order does not matter
But UUIDv4 has one famous drawback: it does not sort by creation time.
If you use UUIDv4 as a clustered primary key in a database, new inserts are randomly distributed throughout the index. For B-tree indexes, that can mean more page splits, worse locality, and more index churn compared to sequential or time-ordered identifiers.
That does not mean UUIDv4 is always bad for databases. It means you should understand the storage and indexing cost instead of assuming all IDs behave like integers.
UUIDv7
UUIDv7 was designed to solve a common modern problem: we want UUIDs, but we also want better time-ordering behavior.
RFC 9562 defines UUIDv7 as a UUID with:
- a 48-bit Unix timestamp in milliseconds at the beginning
- version and variant bits
- 74 bits available for randomness, or a mix of monotonic counters and randomness
The important part is the layout. Because the timestamp sits at the most significant end of the ID, UUIDv7 values generated over time sort naturally by byte order and string order.
Using ramsey/uuid, it looks like this:
use Ramsey\Uuid\Uuid;
$eventId = Uuid::uuid7()->toString();
echo $eventId;
// 0194f21a-7d7b-7333-9f4d-1e6f6c7b6b71
This makes UUIDv7 a very strong default for new systems that want UUID semantics and better database locality.
The mental model is:
UUIDv7 gives you the distributed uniqueness story of UUIDs with an ordering shape that behaves much better for append-heavy systems.
There are still details to consider. UUIDv7 exposes the approximate creation time. That is usually fine for events, logs, orders, and many public resources, but it may matter in domains where timing itself is sensitive.
Also, "sortable" does not mean "perfect sequence number". Multiple IDs generated in the same millisecond still need randomness or counters to avoid collisions and preserve monotonicity. Good libraries handle this, but the distinction matters.
ULIDs: Compact and Lexicographically Sortable
ULID stands for Universally Unique Lexicographically Sortable Identifier.
Like UUIDs, a ULID is 128 bits. The layout is:
01AN4Z07BY 79KA1307SR9X4MV3
|----------| |----------------|
timestamp randomness
48 bits 80 bits
The timestamp is Unix time in milliseconds. The random part is 80 bits. The canonical string is 26 characters using Crockford's Base32 alphabet:
01ARZ3NDEKTSV4RRFFQ69G5FAV
That gives ULIDs a few nice properties:
- they are shorter than canonical UUID strings
- they are URL-safe
- they are case-insensitive by design
- they sort lexicographically by time
- they still fit into 128 bits
In PHP, using robinvdvleuten/ulid, generating one looks like this:
use Ulid\Ulid;
$articleId = Ulid::generate();
echo (string) $articleId;
// 01JGY7TV4J5XJ6BQWQ9S7F0X9Z
You can also read the timestamp represented by the ULID:
use Ulid\Ulid;
$articleId = Ulid::generate();
echo $articleId->toTimestamp();
// 1561622862
That compatibility is useful because some infrastructure is UUID-shaped even when your application prefers ULID strings.
ULID Monotonicity
ULID's timestamp-first layout gives it natural sorting across different milliseconds. But what happens when you generate several ULIDs in the same millisecond?
The ULID spec describes a monotonic strategy: when the same millisecond is detected, the random component can be incremented so the next ULID sorts after the previous one.
That gives you this behavior:
01BX5ZZKBKACTAV9WEVGEMMVRZ
01BX5ZZKBKACTAV9WEVGEMMVS0
01BX5ZZKBKACTAV9WEVGEMMVS1
The timestamp part is the same, but the random part moves forward.
This matters because "timestamp based" is not enough by itself. If your generator creates many IDs in the same millisecond, a monotonic implementation can preserve a more useful ordering than pure randomness within that millisecond.
As with UUIDv7, this does not turn ULIDs into a business sequence. It gives you a stable technical ordering, not a gapless invoice number.
Sqids: Generated IDs From Numbers
Now let's switch mental models.
Sqids are generated IDs, but they are not standalone 128-bit distributed identifiers. A Sqid is a short, URL-safe ID generated from one or more non-negative numbers.
Using the official PHP package:
use Sqids\Sqids;
$sqids = new Sqids();
$publicId = $sqids->encode([1, 2, 3]);
$numbers = $sqids->decode($publicId);
echo $publicId;
// 86Rf07
var_dump($numbers);
// [1, 2, 3]
This is very useful when your database already uses integer IDs, but you want a different public ID shape instead of URLs like this:
/orders/12345
Instead, you can expose:
/orders/NkK9q
Then decode it at the boundary:
use Sqids\Sqids;
final readonly class PublicOrderId
{
public function __construct(private Sqids $sqids) {}
public function encode(int $orderId): string
{
return $this->sqids->encode([$orderId]);
}
public function decode(string $publicId): int
{
$numbers = $this->sqids->decode($publicId);
if (count($numbers) !== 1) {
throw new InvalidArgumentException('Invalid public order ID.');
}
return $numbers[0];
}
}
This keeps the domain model simple. Your database can still use efficient integer keys, while your public routes use Sqids generated from those keys.
But we need to be honest about the trade-off:
Sqids are reversible. Anyone with the alphabet and enough effort can decode them back to numbers.
The official docs are very clear about this: Sqids are not encryption and should not be used for sensitive data.
Sqids and Canonical IDs
There is one Sqids detail that is easy to miss.
Because of the algorithm's design, decoding random strings can sometimes produce the same sequence of numbers. If your application needs to know that an ID is the canonical Sqid for those numbers, you should decode it, re-encode the result, and compare.
use Sqids\Sqids;
function isCanonicalSqid(Sqids $sqids, string $id): bool
{
$numbers = $sqids->decode($id);
if ($numbers === []) {
return false;
}
return $sqids->encode($numbers) === $id;
}
This is especially useful when public URLs should have one canonical form. If someone visits a non-canonical form that decodes to the same internal ID, you can reject it or redirect to the canonical URL.
Sqids also support a minimum length:
use Sqids\Sqids;
$sqids = new Sqids(minLength: 10);
echo $sqids->encode([1, 2, 3]);
// 86Rf07xd4z
And a custom alphabet:
use Sqids\Sqids;
$sqids = new Sqids(
alphabet: 'FxnXM1kBN6cuhsAvjW3Co7l2RePyY8DwaU04Tzt9fHQrqSVKdpimLGIJOgb5ZE',
);
A custom alphabet changes the output, but it should not be treated as a secret. It is a formatting choice and a weak obfuscation layer, not a security mechanism.
How They Behave in Databases
Database behavior is where this comparison becomes practical.
Imagine three tables:
orders_with_uuid_v4(id CHAR(36) PRIMARY KEY)
orders_with_uuid_v7(id CHAR(36) PRIMARY KEY)
orders_with_integer_id(id BIGINT UNSIGNED PRIMARY KEY)
All three can work. But they do not behave the same.
UUIDv4 values are random. If they are used as the primary key, inserts land throughout the index. That can be costly for write-heavy tables.
UUIDv7 and ULID values are time-ordered. New values usually land near the end of the index, which is friendlier for B-tree locality.
Integer IDs are compact and naturally sequential. They are still excellent for internal relational data, especially when the system does not need distributed ID generation.
Sqids do not have to change the database key. They usually sit above the database as generated public IDs derived from internal numbers:
flowchart LR
A[Public URL /orders/NkK9q] --> B[Decode Sqid]
B --> C[Integer ID 12345]
C --> D[Database lookup by primary key]
Sqids are often best used as generated public IDs derived from internal integer keys.
This is why I usually do not think of Sqids as a direct primary-key strategy. I think of them as generated public IDs for numeric identifiers that already exist in your system.
Storing UUIDs and ULIDs
UUIDs and ULIDs are 128-bit values. You can store them as strings, but that is not the only option.
Common choices are:
CHAR(36)for canonical UUID stringsCHAR(26)for ULID stringsBINARY(16)for compact 16-byte storage
String storage is easy to inspect and debug. Binary storage is smaller and can be better for index size, but it requires consistent conversion at the application boundary.
For UUIDs with ramsey/uuid, you can get the binary bytes:
use Ramsey\Uuid\Uuid;
$uuid = Uuid::uuid7();
$string = $uuid->toString();
$bytes = $uuid->getBytes();
For ULIDs, the common application-facing format is the 26-character Base32 string. If you decide to
store UUIDs or ULIDs as BINARY(16), make that conversion explicit and test it well, because the
storage detail should not leak into the rest of your application.
My practical advice is simple: if the table is small or moderate and developer ergonomics matter more, string storage is fine. If the table is large, write-heavy, or index size matters, benchmark binary storage and time-ordered formats before committing to a design.
Security and Privacy
This is the section where a lot of ID choices go wrong.
Identifiers are not automatically secrets.
UUIDv4 is hard to guess when generated with secure randomness, but once a UUID is visible in a URL, it is still an identifier. It should not replace authorization.
UUIDv7 and ULID expose approximate creation time. That can be useful for debugging and ordering, but it can also leak information. If exposing creation timing is a problem, use a different public ID or add another layer for that surface.
Sqids are explicitly reversible. They make numbers look nicer, but they do not protect sensitive data. If exposing the decoded number would be a security issue, Sqids are the wrong tool.
This is a good rule:
If access to a resource would be dangerous only because someone knows its ID, the problem is not the ID format. The problem is missing authorization.
Use IDs for identification. Use permissions, signatures, expiration, or random tokens for security.
For example, a password reset token should not be a Sqid around a user ID:
// Bad idea for a sensitive token.
$token = $sqids->encode([$userId]);
It should be generated from secure random bytes:
$token = bin2hex(random_bytes(32));
That token is not meant to be decoded. It is meant to be unguessable.
Public IDs and Internal IDs
One design I like is separating internal identity from public identity.
For example, an orders table can have:
- an internal integer
idused for joins - a public UUIDv7 or ULID used in APIs
- or an integer
idexposed through Sqids in URLs
Each approach is valid, but they communicate different design choices.
Integer ID plus Sqid
Use this when you want fast relational storage and only need nicer public URLs.
$publicId = $sqids->encode([$order->id]);
This is simple and efficient. The trade-off is that the public ID is reversible.
UUIDv7 or ULID as the public ID
Use this when the public ID should be generated independently and not reveal database sequence.
use Ulid\Ulid;
$orderPublicId = (string) Ulid::generate();
This is a good fit for APIs, distributed systems, and records that may be created outside the main database flow.
UUIDv7 or ULID as the primary key
Use this when distributed generation matters enough that you want the primary key itself to be globally unique.
This can simplify external references, but it changes database storage and indexing characteristics. Do not make that decision only because the ID looks modern.
A Practical Decision Flow
Here is the decision flow I usually follow.
flowchart TD
A[Need an identifier] --> B{Do you already have an integer ID?}
B -->|Yes| C{Is this only for public presentation?}
C -->|Yes| D[Use Sqids if reversibility is acceptable]
C -->|No| E[Keep the integer internally]
B -->|No| F{Need distributed generation?}
F -->|No| G[An integer key may still be enough]
F -->|Yes| H{Need time ordering?}
H -->|Yes| I[Use UUIDv7 or ULID]
H -->|No| J[Use UUIDv4]
The right ID format depends on the job, not on which format is currently popular.
In plain language:
- choose UUIDv4 when you need opaque distributed IDs and do not care about ordering
- choose UUIDv7 when you want standard UUIDs with better time-ordering behavior
- choose ULID when you want compact, URL-safe, lexicographically sortable 128-bit IDs
- choose Sqids when you already have numbers and want short public IDs generated from them
- choose random tokens when the value must be secret or unguessable
That last point is important enough to repeat: random tokens are a different category. Do not use Sqids, ULIDs, or timestamp-based IDs as sensitive secrets just because they look random.
A Small PHP Abstraction
If the application uses more than one ID style, I like keeping that choice at the boundary instead of scattering package calls everywhere.
For example:
interface PublicIdGenerator
{
public function generate(): string;
}
A UUIDv7 implementation could be:
use Ramsey\Uuid\Uuid;
final readonly class UuidV7PublicIdGenerator implements PublicIdGenerator
{
public function generate(): string
{
return Uuid::uuid7()->toString();
}
}
A ULID implementation could be:
use Ulid\Ulid;
final readonly class UlidPublicIdGenerator implements PublicIdGenerator
{
public function generate(): string
{
return (string) Ulid::generate();
}
}
For Sqids, I would not use the same interface because Sqids need an input number. That difference is part of the design and should stay visible:
use Sqids\Sqids;
final readonly class PublicIntegerIdEncoder
{
public function __construct(private Sqids $sqids) {}
public function encode(int $id): string
{
return $this->sqids->encode([$id]);
}
public function decode(string $publicId): int
{
$numbers = $this->sqids->decode($publicId);
if (count($numbers) !== 1 || $this->sqids->encode($numbers) !== $publicId) {
throw new InvalidArgumentException('Invalid public ID.');
}
return $numbers[0];
}
}
This small separation prevents a common architectural mistake: pretending all ID formats are interchangeable because they are strings.
They are not interchangeable. They carry different guarantees.
Common Mistakes
Let's finish the comparison by calling out a few mistakes I see often.
Treating Sqids as encryption
Sqids make IDs prettier. They do not make data secret.
If decoding the value would expose something sensitive, do not use Sqids for that surface.
Using UUIDv4 everywhere by default
UUIDv4 is great, but UUIDv7 is often a better default for new write-heavy systems because it keeps the UUID standard shape while improving time locality.
Assuming ULIDs are secret because they look random
ULIDs contain a timestamp. They are useful and compact, but they are not secret tokens.
Using public IDs without authorization
A hard-to-guess ID is not an authorization system. Always check whether the current actor is allowed to access the resource.
Optimizing ID storage without measuring
Binary UUID storage can be better, but it also adds conversion complexity. Use it when the storage and index benefits are worth that complexity.
Conclusion
UUIDs, ULIDs, and Sqids are all useful, but they are useful in different ways.
UUIDv4 is the classic distributed random identifier. UUIDv7 is the modern UUID choice I would reach for when time ordering matters. ULID gives you a compact, sortable, URL-safe 128-bit ID. Sqids are excellent when you already have integer IDs and want a friendlier public representation.
The most important lesson is not the syntax of any package. It is understanding the properties you need:
- Do you need to generate the ID before persistence?
- Does it need to sort by time?
- Will it be exposed publicly?
- Is it okay if it can be decoded?
- Is it meant to identify something or secure something?
Answer those questions first, and the right format usually becomes obvious.
I hope that you liked this article and if you do, don't forget to share this article with your friends!!! See ya!