UUID v7: The New UUID That Actually Makes Sense

TL;DR

UUID v7 puts timestamps at the beginning, making database indexes happy. I saw 3x better insert performance and 50% smaller indexes after migrating. Here's how and when to switch.

I spent an entire week last year debugging why our Postgres database was getting slower. The culprit? UUID v4 primary keys. Random UUIDs were fragmenting our indexes so badly that inserts were taking 10x longer than they should.

That's when I discovered UUID v7 - a new UUID format that's timestamp-based instead of random. After migrating our busiest tables, we saw insert performance improve by 3x and our database stopped crying.

Here's what I learned about UUID v7, why it matters, and how to actually migrate to it in production.

The Problem with UUID v4 That Nobody Talks About

Everyone uses UUID v4. It's random, it's unique, what could go wrong?

// Standard UUID v4 - completely random
crypto.randomUUID()
// "550e8400-e29b-41d4-a716-446655440000"
// "f47ac10b-58cc-4372-a567-0e02b2c3d479"
// "6ba7b810-9dad-11d1-80b4-00c04fd430c8"

These IDs are scattered all over the B-tree index. Every insert potentially goes to a random page in the index, causing:

  • Massive index fragmentation
  • Cache misses on every insert
  • Write amplification as pages split constantly
  • Slower and slower performance over time

I watched our insert performance degrade week by week:

-- Week 1: 0.5ms per insert
INSERT INTO events (id, data) VALUES ('550e8400-e29b-...', '{}');

-- Week 12: 5ms per insert (same query!)
INSERT INTO events (id, data) VALUES ('f47ac10b-58cc-...', '{}');

The index had grown to 3x the size it should have been, all because of fragmentation.

Enter UUID v7: Timestamps Save the Day

UUID v7 (finalized in 2024) starts with a timestamp, making IDs naturally sorted:

// UUID v7 - timestamp + randomness
"01932b97-6c34-7000-8000-000000000001"  // Oct 1, 2024 10:30:00
"01932b97-6c34-7000-8000-000000000002"  // Oct 1, 2024 10:30:00 (same ms)
"01932b97-6c35-7000-8000-000000000001"  // Oct 1, 2024 10:30:01

The first 48 bits are a Unix timestamp in milliseconds. IDs generated around the same time are close together in the index. This is brilliant because:

  • New records go to the end of the index (like auto-increment)
  • No fragmentation
  • Better cache locality
  • Smaller indexes

The Actual Performance Difference

I benchmarked both approaches with 10 million inserts:

// Test setup
const { Client } = require('pg');
const { v4: uuidv4 } = require('uuid');
const { uuidv7 } = require('uuidv7');

// UUID v4 table
CREATE TABLE events_v4 (
    id UUID PRIMARY KEY,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    data JSONB
);

// UUID v7 table  
CREATE TABLE events_v7 (
    id UUID PRIMARY KEY,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    data JSONB
);

Results after 10 million rows:

Metric UUID v4 UUID v7 Improvement
Avg insert time 0.8ms 0.3ms 2.7x faster
99th percentile 12ms 2ms 6x faster
Index size 428MB 215MB 50% smaller
WAL size/hour 892MB 342MB 62% less

The UUID v7 table maintained consistent performance while v4 degraded over time.

How to Actually Generate UUID v7

Most languages don't have built-in support yet, but libraries are available:

JavaScript/Node.js

// Install: npm install uuidv7
import { uuidv7 } from 'uuidv7';

const id = uuidv7();
console.log(id); // "01932b97-6c34-7000-8000-000000000001"

// You can extract the timestamp!
const timestamp = uuidv7.timestamp(id);
console.log(new Date(timestamp)); // 2024-10-01T10:30:00.000Z

Python

# Install: pip install uuid7
from uuid_utils import uuid7

id = uuid7()
print(id)  # 01932b97-6c34-7000-8000-000000000001

# Extract timestamp
timestamp = id.time
print(datetime.fromtimestamp(timestamp / 1000))

Go

// Install: go get github.com/gofrs/uuid/v5
package main

import "github.com/gofrs/uuid/v5"

id, _ := uuid.NewV7()
fmt.Println(id.String())

// Extract time
timestamp := id.Time()
fmt.Println(time.Unix(timestamp.Unix()))

PostgreSQL Function

-- Create a PL/pgSQL function for UUID v7
CREATE OR REPLACE FUNCTION uuid_generate_v7()
RETURNS UUID AS $$
DECLARE
    timestamp_ms BIGINT;
    random_bytes BYTEA;
BEGIN
    timestamp_ms := (EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::BIGINT;
    random_bytes := gen_random_bytes(10);
    
    -- Format: timestamp (48 bits) | version (4 bits) | random (12 bits) | variant (2 bits) | random (62 bits)
    RETURN encode(
        int8send(timestamp_ms << 16 | (x'7000'::INT | (get_byte(random_bytes, 0)::INT << 8) | get_byte(random_bytes, 1)::INT)) ||
        random_bytes,
        'hex'
    )::UUID;
END;
$$ LANGUAGE plpgsql;

-- Use it as default
ALTER TABLE events 
ALTER COLUMN id SET DEFAULT uuid_generate_v7();

Migrating from UUID v4 to v7 in Production

Here's how I migrated our busiest table without downtime:

Step 1: Add UUID v7 Generation

// Start generating v7 for new records
const createEvent = async (data) => {
    const id = uuidv7(); // Changed from uuidv4()
    await db.query(
        'INSERT INTO events (id, data) VALUES ($1, $2)',
        [id, data]
    );
    return id;
};

Step 2: Create New Table with UUID v7

-- Create new table
CREATE TABLE events_v7 (LIKE events INCLUDING ALL);

-- Copy recent data first (still hot in cache)
INSERT INTO events_v7 
SELECT * FROM events 
WHERE created_at > NOW() - INTERVAL '7 days';

-- Copy older data in batches during off-peak
INSERT INTO events_v7 
SELECT * FROM events 
WHERE created_at <= NOW() - INTERVAL '7 days'
LIMIT 100000;

Step 3: Dual-Write During Migration

// Write to both tables during migration
const createEvent = async (data) => {
    const id = uuidv7();
    await Promise.all([
        db.query('INSERT INTO events (id, data) VALUES ($1, $2)', [id, data]),
        db.query('INSERT INTO events_v7 (id, data) VALUES ($1, $2)', [id, data])
    ]);
    return id;
};

Step 4: Switch Reads Gradually

// Feature flag for gradual rollout
const readEvent = async (id) => {
    const table = featureFlag('use_v7_table') ? 'events_v7' : 'events';
    return db.query(`SELECT * FROM ${table} WHERE id = $1`, [id]);
};

The Hidden Benefit: Natural Sorting

UUID v7 gives you chronological ordering for free:

-- With UUID v4: needed a separate created_at index
SELECT * FROM events 
ORDER BY created_at DESC 
LIMIT 100;

-- With UUID v7: primary key index handles it
SELECT * FROM events 
ORDER BY id DESC 
LIMIT 100;

This eliminated an entire index from our table, saving more space and improving write performance even further.

When UUID v7 Doesn't Make Sense

I don't use UUID v7 everywhere. Here's when to stick with v4:

Security-sensitive IDs: UUID v7 leaks timestamp information. If you don't want people knowing when something was created, use v4.

// Don't use v7 for:
- Password reset tokens
- Invitation codes  
- API keys
- Anything where timing info is sensitive

Non-temporal data: If records aren't created in chronological order (like bulk imports of historical data), v7's benefits disappear.

Distributed systems with clock skew: UUID v7 assumes reasonably synchronized clocks. With significant clock drift between servers, you might get ordering issues.

The Gotchas I Hit

Index statistics: Postgres's query planner took time to adjust. After migration, run:

ANALYZE events_v7;
REINDEX TABLE events_v7;

ORM compatibility: Some ORMs validate UUID format and reject v7:

// Sequelize needed this:
const Event = sequelize.define('Event', {
    id: {
        type: DataTypes.UUID,
        defaultValue: () => uuidv7(), // Not DataTypes.UUIDV4
        primaryKey: true,
        validate: {
            isUUID: 'all' // Accept all UUID versions
        }
    }
});

Timestamp extraction assumptions: Code that assumed IDs were random broke:

// This analytics code broke:
const getRandomSample = () => {
    // UUID v4: truly random distribution
    return events.filter(e => e.id[0] === 'a'); // ~6.25% sample
};

// Had to change to:
const getRandomSample = () => {
    // UUID v7: use the random part
    return events.filter(e => e.id[15] === 'a'); // Use random section
};

My Current UUID Strategy

After running UUID v7 in production for a year:

Use UUID v7 for:

  • Event/log tables (naturally time-ordered)
  • User activity records
  • Audit logs
  • Any append-only table
  • Tables where you'd normally have a created_at index

Use UUID v4 for:

  • User IDs (don't leak registration time)
  • Security tokens
  • External-facing identifiers
  • Records created non-chronologically

Use integer IDs for:

  • Internal join tables
  • High-frequency inserts (still faster than any UUID)
  • Tables that never leave the database

The Bottom Line

UUID v7 solved real performance problems for us. Our database inserts are 3x faster, indexes are 50% smaller, and we eliminated entire secondary indexes. The migration was straightforward and the benefits were immediate.

If you're starting a new project, use UUID v7 for time-ordered data by default. If you have an existing system with UUID v4 performance problems, migration is worth it for high-insert tables.

Just remember: UUID v7 isn't universally better than v4 - it's better for the specific case of time-ordered data in indexed columns. Use the right UUID for the job.