UUID v7

Ilustración comparativa estilo cómic: a la izquierda Jorge con UUID-V4 atrapado en una pista enredada y caótica, a la derecha Jorge con UUID-V7 avanzando por una pista de atletismo ordenada y recta.

Tabla de contenidos


Mi Problema 🤔

En mi proyecto uso PostgreSQL como base de datos y UUID como identificador en todos mis modelos. Es el estándar habitual: universal, sin colisiones, seguro. Pero me encontré con un problema cuando mis tablas empezaron a crecer.

UUID v4 es completamente aleatorio. Esto significa que cada nuevo registro se inserta en una posición aleatoria dentro del índice B-Tree. El índice se fragmenta, las páginas se dividen, y el rendimiento de escritura degrada progresivamente. En tablas con millones de registros, el impacto es real.

La solución existe desde hace tiempo en bases de datos como MongoDB con ObjectID o en sistemas que usan ULID: embeber el timestamp en el identificador para que los registros nuevos se inserten siempre al final del índice.

UUID v7, definido en el RFC 9562, trae exactamente eso al estándar UUID. PostgreSQL 18 ya incluye la función nativa uuidv7(). Pero Swift — y por extensión Fluent — sigue generando UUID v4 por defecto con UUID().

Si el identificador lo genera mi aplicación antes de persistirlo, estoy perdiendo las ventajas de v7 aunque mi base de datos ya lo soporte.


Mi Solución 🧩

Decidí crear una extensión sobre UUID que implementa la generación de UUID v7 directamente en Swift, siguiendo la especificación del RFC 9562.

Estado monotónico

Lo primero que necesito es un estado compartido que garantice que dos UUIDs generados en el mismo milisegundo sean distintos y estén ordenados. Uso un Mutex del framework Synchronization para hacerlo thread-safe sin recurrir a DispatchQueue ni actor:

import Foundation
import Synchronization

private let v7State = Mutex(V7State())

private struct V7State: Sendable {
    var lastTimestamp: UInt64 = 0
    var sequence: UInt16 = 0
}

La generación del UUID

La extensión principal implementa el método estático v7() que genera un UUID conforme al RFC 9562:

extension UUID {
    public static func v7() -> UUID {
        var bytes = (
            UInt8(0), UInt8(0), UInt8(0), UInt8(0),
            UInt8(0), UInt8(0), UInt8(0), UInt8(0),
            UInt8(0), UInt8(0), UInt8(0), UInt8(0),
            UInt8(0), UInt8(0), UInt8(0), UInt8(0)
        )

        let (timestamp, seq) = v7State.withLock { state -> (UInt64, UInt16) in
            var now = UInt64(Date().timeIntervalSince1970 * 1000)

            if now == state.lastTimestamp {
                if state.sequence >= 0x0FFF {
                    while now == state.lastTimestamp {
                        now = UInt64(Date().timeIntervalSince1970 * 1000)
                    }
                    state.lastTimestamp = now
                    state.sequence = UInt16.random(in: 0...0x0FFF)
                } else {
                    state.sequence += 1
                }
            } else {
                state.lastTimestamp = now
                state.sequence = UInt16.random(in: 0...0x0FFF)
            }

            return (now, state.sequence)
        }

        // Bytes 0–5: 48-bit timestamp (big-endian)
        bytes.0 = UInt8(truncatingIfNeeded: timestamp >> 40)
        bytes.1 = UInt8(truncatingIfNeeded: timestamp >> 32)
        bytes.2 = UInt8(truncatingIfNeeded: timestamp >> 24)
        bytes.3 = UInt8(truncatingIfNeeded: timestamp >> 16)
        bytes.4 = UInt8(truncatingIfNeeded: timestamp >> 8)
        bytes.5 = UInt8(truncatingIfNeeded: timestamp)

        // Byte 6: version (0x7_) | top 4 bits of sequence
        bytes.6 = 0x70 | UInt8(seq >> 8)

        // Byte 7: lower 8 bits of sequence
        bytes.7 = UInt8(truncatingIfNeeded: seq)

        // Bytes 8–15: random, then set variant
        var random: UInt64 = 0
        withUnsafeMutableBytes(of: &random) { buf in
            _ = SecRandomCopyBytes(kSecRandomDefault, buf.count, buf.baseAddress!)
        }
        bytes.8  = UInt8(truncatingIfNeeded: random >> 56)
        bytes.9  = UInt8(truncatingIfNeeded: random >> 48)
        bytes.10 = UInt8(truncatingIfNeeded: random >> 40)
        bytes.11 = UInt8(truncatingIfNeeded: random >> 32)
        bytes.12 = UInt8(truncatingIfNeeded: random >> 24)
        bytes.13 = UInt8(truncatingIfNeeded: random >> 16)
        bytes.14 = UInt8(truncatingIfNeeded: random >> 8)
        bytes.15 = UInt8(truncatingIfNeeded: random)

        // Byte 8: variant 0b10xx_xxxx
        bytes.8 = (bytes.8 & 0x3F) | 0x80

        return UUID(uuid: bytes)
    }
}

La estructura del UUID generado respeta la especificación del RFC 9562:

  • Bytes 0–5 (48 bits): timestamp en milisegundos desde epoch, en big-endian. Esto es lo que garantiza el orden cronológico.
  • Byte 6 (nibble alto): versión 0x7 — identifica el UUID como v7.
  • Bytes 6–7 (12 bits inferiores): contador de secuencia. Garantiza monotonicidad dentro del mismo milisegundo.
  • Bytes 8–15 (64 bits): aleatorios generados con SecRandomCopyBytes, con los dos bits superiores del byte 8 forzados a 0b10 para marcar el variante RFC 4122.

La lógica monotónica dentro del withLock funciona así: cuando dos llamadas ocurren en el mismo milisegundo, el contador se incrementa. Si el contador alcanza el máximo (0x0FFF, es decir 4095 UUIDs en un solo milisegundo), la implementación espera activamente al siguiente milisegundo antes de continuar — nunca genera dos UUIDs idénticos.

Utilidades de inspección

Además de la generación, añadí dos propiedades para poder inspeccionar cualquier UUID: una que verifica si es v7 y otra que extrae el timestamp embebido:

extension UUID {
    public var isV7: Bool {
        (uuid.6 >> 4) == 0x07 && (uuid.8 >> 6) == 0x02
    }

    public var v7Timestamp: Date? {
        guard isV7 else { return nil }

        let ms = UInt64(uuid.0) << 40
            | UInt64(uuid.1) << 32
            | UInt64(uuid.2) << 24
            | UInt64(uuid.3) << 16
            | UInt64(uuid.4) << 8
            | UInt64(uuid.5)

        return Date(timeIntervalSince1970: Double(ms) / 1000.0)
    }
}

isV7 comprueba que el nibble alto del byte 6 sea 0x07 (versión 7) y que los dos bits superiores del byte 8 sean 0b10 (variante RFC 4122). v7Timestamp reconstruye los 48 bits del timestamp desde los bytes 0–5 y los convierte en un Date.


Mi Resultado 🎯

La adopción es inmediata. En cualquier modelo Fluent, me basta con sustituir UUID() por UUID.v7() en el inicializador:

// Antes
self.id = UUID()

// Después
self.id = UUID.v7()

El tipo en base de datos no cambia — sigue siendo UUID. Fluent no necesita ninguna modificación. El cambio es completamente transparente para el ORM.

Los beneficios que he obtenido son directos:

  • Índices ordenados — los nuevos registros se insertan siempre al final del B-Tree, eliminando la fragmentación
  • Ordenación cronológica nativaORDER BY id equivale a ORDER BY created_at sin columna extra
  • Timestamp embebido — puedo extraer cuándo se creó un registro directamente del UUID con v7Timestamp
  • Compatible hacia atrás — el formato sigue siendo UUID estándar; cualquier sistema que acepte UUID v4 acepta v7
  • Thread-safe — funciona correctamente desde múltiples hilos concurrentes gracias al Mutex
let id = UUID.v7()

print(id.isV7)          // true
print(id.v7Timestamp!)  // la fecha de creación embebida en el UUID

Y si ya estás en PostgreSQL 18, la función nativa uuidv7() genera UUIDs con la misma estructura. Los UUIDs generados desde Swift y desde PostgreSQL son intercambiables y comparables entre sí.

El cambio de UUID() a UUID.v7() es de una línea. Las ventajas, permanentes.

Keep coding, keep running 🏃‍♂️