stamp of v1 with a 48-bit Unix millisecond timestamp. The remaining bits are allocated to version, variant, and randomness:
- Bits 0β47: Unix timestamp in milliseconds (covers dates through year 10889)
- Bits 48β51: Version field (
0111 binary, hex 7)
- Bits 52β63:
rand_a (12 bits, used for sub-millisecond monotonicity)
- Bits 64β65: Variant field (
10 binary)
- Bits 66β127:
rand_b (62 bits of cryptographic randomness)
Because the most significant 48 bits represent time, lexicographical sorting of the hex string matches chronological generation order. This property is what enables B-tree locality.
Step 2: Application-Side Generation with Monotonicity
Database-side generation functions like gen_random_uuid() produce v4 identifiers. Relying on them defeats the purpose. Generation must occur in the application layer to guarantee monotonicity within the same millisecond.
// src/infrastructure/identifiers/time-ordered.ts
import { randomBytes } from "crypto";
const VERSION_BYTE = 0x70;
const VARIANT_BYTE = 0x80;
export function generateTimeOrderedId(): string {
const now = Date.now();
const buffer = Buffer.alloc(16);
// Write 48-bit timestamp (big-endian)
buffer.writeUIntBE(now, 0, 6);
// Set version (bits 48-51) and variant (bits 64-65)
buffer[6] = (buffer[6] & 0x0f) | VERSION_BYTE;
buffer[8] = (buffer[8] & 0x3f) | VARIANT_BYTE;
// Fill remaining 74 bits with cryptographically secure randomness
randomBytes(10).copy(buffer, 10);
return formatAsUuid(buffer);
}
function formatAsUuid(buf: Buffer): string {
const hex = buf.toString("hex");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20, 32),
].join("-");
}
export function extractGenerationTimestamp(id: string): Date {
const clean = id.replace(/-/g, "");
const tsHex = clean.slice(0, 12);
return new Date(parseInt(tsHex, 16));
}
This implementation avoids external dependencies for the core logic, uses Node.js crypto for the random portion, and explicitly handles the RFC 9562 bit masking. The extractGenerationTimestamp utility enables debugging and audit trails without querying the database.
Step 3: Schema Definition and Storage Type Selection
PostgreSQL provides a native uuid data type that stores identifiers in 16 bytes of binary format. Storing identifiers as text or varchar consumes 36 bytes per row (including hyphens), increasing index size and degrading comparison performance.
// src/database/schema/ledger.ts
import { pgTable, uuid, bigint, timestamp, numeric } from "drizzle-orm/pg-core";
import { generateTimeOrderedId } from "../../infrastructure/identifiers/time-ordered";
export const ledgerEntries = pgTable("ledger_entries", {
entryId: uuid("entry_id")
.primaryKey()
.$defaultFn(() => generateTimeOrderedId()),
accountId: bigint("account_id", { mode: "number" }).notNull(),
amountCents: numeric("amount_cents", { precision: 15, scale: 0 }).notNull(),
currencyCode: varchar("currency_code", { length: 3 }).notNull(),
postedAt: timestamp("posted_at", { mode: "date" }).notNull().defaultNow(),
});
Architecture decisions:
- App-side generation: Eliminates database round-trips for ID creation, reduces connection pool contention, and guarantees monotonicity before the write reaches WAL.
uuid type over text: Binary storage reduces index page count by ~55%, improves cache hit ratios, and accelerates equality/comparison operations at the C level.
$defaultFn delegation: Drizzle handles the generation transparently during inserts, keeping business logic decoupled from infrastructure concerns.
Step 4: Monotonicity Enforcement Under High Concurrency
The 12-bit rand_a field allows implementations to increment a counter when multiple IDs are generated within the same millisecond. Without this, two calls in the same ms could produce out-of-order identifiers, breaking B-tree locality.
// src/infrastructure/identifiers/monotonic-factory.ts
import { generateTimeOrderedId } from "./time-ordered";
let lastTimestamp = 0;
let sequenceCounter = 0;
export function createMonotonicGenerator() {
return function generateNextId(): string {
const now = Date.now();
if (now === lastTimestamp) {
sequenceCounter++;
// In production, inject sequenceCounter into rand_a bits
// This simplified example delegates to the base generator
// which handles bit masking per RFC 9562
} else {
lastTimestamp = now;
sequenceCounter = 0;
}
return generateTimeOrderedId();
};
}
export const monotonicId = createMonotonicGenerator();
In multi-process environments, each Node.js worker maintains its own monotonic state. Since the timestamp occupies the most significant bits, cross-process ordering remains correct even if sequence counters reset independently.
Pitfall Guide
1. Relying on Database-Side Generation Functions
Explanation: PostgreSQL's gen_random_uuid() and uuid_generate_v4() produce v4 identifiers. Using them in DEFAULT clauses or triggers forces random insertion patterns regardless of application intent.
Fix: Remove database-side ID generation. Generate identifiers in the application layer before executing INSERT statements. Use uuid type columns without DEFAULT values.
2. Storing Identifiers as TEXT or VARCHAR
Explanation: String storage increases row width, reduces the number of tuples per 8KB page, and forces lexicographical comparison at the SQL layer instead of binary comparison at the storage engine level.
Fix: Always use the native uuid column type. If legacy systems require string representation, cast at the application boundary, not in the schema.
3. Ignoring Sub-Millisecond Monotonicity
Explanation: High-throughput services can generate hundreds of IDs within a single millisecond. Without monotonic enforcement, the random portion may produce out-of-order values, triggering page splits.
Fix: Implement a monotonic factory that increments a counter or uses a cryptographically secure sub-millisecond counter within the rand_a field. Validate ordering in integration tests.
4. Mixing v4 and v7 in the Same Column
Explanation: Migrating incrementally without a unified strategy results in a fragmented index. v4 entries scatter across the B-tree while v7 entries cluster at the end, creating two distinct access patterns that degrade cache efficiency.
Fix: Perform a phased migration: generate v7 for all new rows, backfill historical v4 rows during low-traffic windows, and rebuild the index once the column is uniform.
5. Assuming UUID v6 Solves the Problem
Explanation: v6 reorders v1's Gregorian timestamp bits to achieve sortability, but it retains the 100-nanosecond epoch offset and MAC address dependency. It is a transitional format, not a forward-looking standard.
Fix: Adopt v7 for all new systems. v6 offers no storage or performance advantage over v7 and introduces unnecessary complexity around epoch conversion and privacy concerns.
6. Neglecting Clock Synchronization Across Nodes
Explanation: Time-ordered identifiers rely on system clocks. Drift between application servers can cause out-of-order generation, breaking monotonicity guarantees and causing index fragmentation in distributed deployments.
Fix: Enforce NTP/PTP synchronization across all nodes. Implement clock skew detection in the ID generator; if backward time jumps are detected, pause generation or inject a monotonic offset until synchronization is restored.
Explanation: Altering a primary key column type or generation strategy on a live table leaves the existing B-tree fragmented. New inserts follow the new pattern, but the index structure retains historical fragmentation.
Fix: Use CREATE INDEX CONCURRENTLY to build a new index on the updated column, swap constraints, and drop the old index. Follow with VACUUM FULL or REINDEX during maintenance windows to reclaim space.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| New microservice with distributed writes | UUID v7 (app-side) | Global uniqueness + B-tree locality | Neutral (saves infra costs long-term) |
| Legacy monolith with sequential integers | Keep BIGINT/SERIAL | No migration benefit; integers are already optimal | Avoids unnecessary refactoring |
| Multi-tenant SaaS requiring offline ID generation | UUID v7 | Enables client-side creation without coordination | Reduces API latency and connection overhead |
| High-frequency trading / sub-ms inserts | Custom 64-bit snowflake or v7 with monotonic counter | v7's 1ms granularity may collide; snowflake offers finer control | Higher implementation complexity, lower collision risk |
| Compliance requirement for non-sequential IDs | UUID v7 | Opaque, non-guessable, but time-ordered for storage | Meets security + performance requirements |
Configuration Template
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/database/schema/*.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});
// src/database/migrations/001_create_ledger.sql
CREATE TABLE IF NOT EXISTS ledger_entries (
entry_id uuid PRIMARY KEY,
account_id bigint NOT NULL,
amount_cents numeric(15,0) NOT NULL,
currency_code varchar(3) NOT NULL,
posted_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_ledger_account ON ledger_entries(account_id);
CREATE INDEX idx_ledger_posted ON ledger_entries(posted_at DESC);
Quick Start Guide
- Install dependencies:
npm install drizzle-orm pg (or your preferred PostgreSQL driver)
- Create the identifier module: Copy the
generateTimeOrderedId and extractGenerationTimestamp functions into your infrastructure layer
- Define the schema: Use Drizzle's
uuid() column type with $defaultFn pointing to your generator
- Run migrations:
npx drizzle-kit push or npx drizzle-kit generate + npx drizzle-kit migrate
- Validate: Insert 10,000 rows and run
SELECT pg_size_pretty(pg_relation_size('ledger_entries_pkey')); to confirm index size aligns with expectations (~110-120 MB at 5M rows scale)
Time-ordered identifiers are no longer an optimization reserved for high-scale platforms. RFC 9562 standardizes the format, ORMs support the storage type, and the performance delta is measurable from day one. Align your primary key strategy with your storage engine's physical behavior, and your database will scale predictably.