ULIDs vs UUIDs vs Timestamps for DynamoDB Sort Keys

In my SaaS multi-tenant schema pattern, I used PROJECT#<createdAt>#<projectId> as a sort key. A reader on reddit (u/Patient-Swordfish906) correctly pointed out a problem with this design:

“Your PROJECT# SK doesn’t support direct lookup by projectId. If I have the projectId, I can’t query for it without knowing the createdAt timestamp.”

They were right. I updated the article, but the underlying question deserves its own post - because sort key design is one of the decisions that ripples through your entire schema.

Why sort key choice matters so much

In DynamoDB, a sort key does two jobs: it makes a record uniquely addressable, and it determines the ordering of records within a partition. These two jobs are in tension, and the type of value you use determines which one you’re optimizing for.

Get it wrong upfront and you’ll either need a GSI to paper over the gap (costs money, adds complexity) or a schema migration to backfill a corrected key format (painful at any scale).

Let’s look at the three common approaches.

Option 1: Timestamps

Example: SK: ORDER#2026-02-19T17:22:00Z

What you get: Records sort chronologically within a partition. Querying “all orders for this customer, newest first” is a simple Query with ScanIndexForward: false. Range queries like “orders placed in the last 30 days” work perfectly.

What you give up: Direct lookup by the entity ID. If you have an orderId and want to fetch that specific order, you have to know the timestamp too - or do a Scan, which defeats the purpose of DynamoDB.

When to use it: When time-based range queries are your most important access pattern and you’ll always have the timestamp available when querying. Logging systems, time-series data, and activity feeds are good fits.

Option 2: UUIDs

Example: SK: ORDER#550e8400-e29b-41d4-a716-446655440000

What you get: Direct lookup by ID. Given an orderId, you can fetch the exact record with a single GetItem. No additional context required.

What you give up: Natural ordering. UUIDs are random by design, so records within a partition have no meaningful sort order. Querying “all orders for this customer, newest first” requires a GSI with a timestamp attribute, or storing a sort-friendly value somewhere.

When to use it: When direct lookup is your primary access pattern and you don’t need records sorted by time within a partition.

Option 3: ULIDs (the best of both worlds)

Example: SK: ORDER#01HVMK3P2QRSV8T4X6Y9Z0A1B2

A ULID (Universally Unique Lexicographically Sortable Identifier) looks like a UUID but is constructed differently:

01HVMK3P2Q  RSV8T4X6Y9Z0A1B2
└──────────┘ └──────────────┘
  timestamp      randomness
  (48 bits)      (80 bits)

The first 10 characters encode a millisecond-precision timestamp. The remaining characters are random. Because ULIDs are base32-encoded and the timestamp comes first, lexicographic sort = chronological sort.

What you get: Both direct lookup AND natural time ordering. Records sort chronologically within a partition, AND you can fetch a specific record if you only have its ID.

What you give up: Almost nothing. ULIDs are slightly longer than UUIDs (26 characters vs 36), and you need a ULID library rather than a built-in UUID function.

When to use each

TimestampUUIDULID
Sort chronologically
Direct lookup by ID
Time range queries
Human readable time
Standard library supportNeeds package
Globally unique

Use timestamps when human readability matters, time-range queries dominate, and you’ll always have the timestamp when querying.

Use UUIDs when direct lookup is all you need and natural ordering doesn’t matter.

Use ULIDs for most everything else - especially entity IDs where you want both time-ordering within a partition and direct lookup by ID. This is the right default for most DynamoDB schemas. The e-commerce orders pattern is a concrete example - order IDs are ULIDs, which gives customer order history sorted newest-first and direct lookup by order ID from a single record type.

Generating ULIDs in TypeScript

import { ulid } from 'ulid';

// Generate a new ULID
const id = ulid(); // "01HVMK3P2QRSV8T4X6Y9Z0A1B2"

// Generate a ULID for a specific timestamp (useful for testing)
const idAtTime = ulid(Date.now());

// Extract the timestamp from a ULID
import { decodeTime } from 'ulid';
const timestamp = decodeTime(id); // milliseconds since epoch

Install with: npm install ulid

ULIDs are monotonically increasing within the same millisecond (the random portion increments rather than being fully random), which prevents sorting anomalies when multiple records are created simultaneously.

How I’d redesign the PROJECT# key

Here’s what I had in the multi-tenant pattern:

PK: TENANT#<tenantId>
SK: PROJECT#<createdAt>#<projectId>   ← broken: can't look up by projectId alone

The problem: to fetch project abc123, I need to know both the tenantId AND the createdAt. Callers typically only have the projectId.

Here’s the fix with ULIDs:

PK: TENANT#<tenantId>
SK: PROJECT#<ulid>                     ← works: ULID encodes time + is unique

Where ulid is generated at project creation time and stored as the project’s ID. Now:

  • List projects newest first: Query(PK=TENANT#<id>, SK begins_with PROJECT#) - sorts chronologically because ULIDs sort lexicographically
  • Fetch specific project: GetItem(PK=TENANT#<id>, SK=PROJECT#<projectId>) - direct lookup works because the ULID is the projectId

The <createdAt> prefix is completely unnecessary when you’re using ULIDs - the timestamp is embedded in the ID itself.

A note on KSUIDs

KSUID is another sortable ID format worth knowing. It uses a 32-bit timestamp (second precision vs ULID’s millisecond precision) with 128 bits of randomness. The tradeoff: slightly less time precision, slightly more collision resistance. For most DynamoDB use cases, ULIDs are the better default - millisecond precision matters when multiple records can be created in the same second.


The fix for the SaaS Multi-Tenant pattern’s PROJECT# key is to use ULIDs - swap PROJECT#<createdAt>#<projectId> for PROJECT#<ulid> and you get both sorting and direct lookup. I’m building singletable.dev to make these sort key tradeoffs visible in a schema designer - so you catch them before they’re in production.