UUID version 1 has been around for a long time. It combines a timestamp, a clock sequence, and a node identifier into a 128-bit value, which makes it useful when applications need identifiers that can be generated without coordinating through a central database sequence.
Its layout has an awkward property, though: the timestamp bits are not arranged from most significant to least significant in the same order that ordinary UUID comparison uses.
That matters when UUIDs become database keys. Values generated close together in time do not have the clean key locality that their time-based nature might suggest.
RFC 9562 defines UUID version 6 as a reordered form of the UUIDv1-style time-based identifier. Python 3.14 adds uuid.uuid6() to the standard library.
UUIDv6 is especially interesting for systems that already chose UUIDv1 semantics and want a more index-friendly layout. It is not simply “UUIDv7 but older,” and it should not automatically replace UUIDv4 or UUIDv7 in every application.
This article focuses on that migration boundary.
Generate a UUIDv6 in Python 3.14
The basic API is deliberately small:
import uuid
identifier = uuid.uuid6()
print(identifier)
print(identifier.version)The version is 6.
Like uuid.uuid1(), uuid.uuid6() accepts optional node and clock_seq arguments:
import uuid
identifier = uuid.uuid6(
node=0x123456789ABC,
clock_seq=1234,
)If node is omitted, Python uses uuid.getnode() to obtain a 48-bit node value. If clock_seq is omitted, Python generates a pseudo-random 14-bit sequence number.
That similarity is important: UUIDv6 changes the bit layout, not the fundamental UUIDv1-style data model.
Why UUIDv1 has awkward sort locality
A UUID is commonly stored and compared as a 128-bit value. Python follows that model too: comparison between UUID objects is based on their UUID.int values.
UUIDv1 contains a timestamp, but its historical field layout splits that timestamp into pieces whose significance does not line up naturally with lexicographic UUID order.
That means this intuition is unsafe:
first = uuid.uuid1()
second = uuid.uuid1()
assert first < secondTwo values generated in that order may often appear conveniently ordered over short intervals, but UUIDv1’s field layout was not designed to make ordinary UUID ordering correspond cleanly to timestamp ordering across the full timestamp space.
UUIDv6 addresses the layout problem by placing the timestamp bits in an order intended to improve database locality.
For an index whose key order follows the UUID’s binary or integer order, recently generated UUIDv6 values therefore have a much more useful relationship to generation time.
Database locality is not the same as a database guarantee
It is tempting to turn “better locality” into a stronger claim than the format provides.
A UUIDv6 value is not a transaction sequence number.
Do not use it as a substitute for:
- a database commit sequence;
- an event-stream offset;
- a consensus log index;
- a monotonic application revision;
- or an authoritative
created_atcolumn.
Clocks can move. Multiple hosts can generate identifiers independently. Concurrent operations can race. An identifier may be generated well before its row is committed.
Use UUIDv6 ordering as a useful physical characteristic, not as business chronology.
If business logic needs ordering, model that ordering explicitly.
Keep the timestamp column
Because UUIDv6 is time-based, an application might try to eliminate a separate creation timestamp.
That usually makes the data model worse.
Keep fields such as:
created_at TIMESTAMP WITH TIME ZONE NOT NULLThe explicit timestamp communicates application meaning. The UUID’s embedded timestamp is part of the identifier format.
Those are different responsibilities.
A dedicated timestamp can also be indexed, queried, migrated, documented, and assigned according to transaction semantics independently of identifier generation.
Inspect the embedded time
Python’s UUID.time attribute exposes the time field for UUID versions 1 and 6 as a count of 100-nanosecond intervals since the Gregorian epoch beginning on 1582-10-15.
For example:
import uuid
identifier = uuid.uuid6()
print(identifier.time)If an application needs to interpret that value, make the epoch conversion explicit rather than treating it as a Unix timestamp.
A helper can look like this:
from datetime import datetime, timedelta, timezone
import uuid
GREGORIAN_EPOCH = datetime(1582, 10, 15, tzinfo=timezone.utc)
def uuid6_datetime(value: uuid.UUID) -> datetime:
if value.version != 6:
raise ValueError("expected a UUIDv6 value")
return GREGORIAN_EPOCH + timedelta(microseconds=value.time / 10)There is a precision detail here: Python’s datetime resolution is microseconds, while the UUIDv6 timestamp uses 100-nanosecond units. A conversion to datetime therefore cannot preserve every timestamp bit.
If exact UUID timestamp arithmetic matters, keep the integer value.time rather than round-tripping through datetime.
UUIDv6 still has node-identifier considerations
UUIDv6 retains a 48-bit node field.
That deserves the same architectural attention as UUIDv1.
Python’s documentation warns that UUIDv1 may compromise privacy because it can contain the computer’s network address. Since uuid6() also uses getnode() when no node is supplied, applications should not assume UUIDv6 removes node-related privacy considerations merely because its timestamp layout is newer.
This is particularly important for identifiers that leave a trusted boundary:
https://api.example.com/orders/<uuid>If exposing a node-derived identifier is inconsistent with the system’s privacy model, UUIDv6 may not be the right public identifier format.
A UUIDv4 or UUIDv7 design may be a better fit, depending on the application’s requirements.
Do not invent a random node on every call
A possible reaction to the node issue is to pass a fresh random 48-bit number every time:
# Usually not a useful UUIDv6 policy.
uuid.uuid6(node=random.getrandbits(48))That changes the generation model in a way that deserves careful analysis rather than being treated as a privacy switch.
If an application deliberately supplies a node value, define where that value comes from, how stable it is supposed to be, and what collision assumptions the design relies on.
Identifier formats are infrastructure. Hidden randomness policies make them harder to reason about.
UUIDv6 and UUIDv7 target different migration stories
Python 3.14 also adds uuid.uuid7().
Both UUIDv6 and UUIDv7 are time-oriented, but choosing between them should start with semantics rather than a benchmark.
UUIDv6 is closely related to UUIDv1. It keeps the UUIDv1-style timestamp, clock sequence, and node model while rearranging the timestamp for improved locality.
UUIDv7 uses a Unix-epoch millisecond timestamp and a different layout. Python’s implementation uses a counter to guarantee monotonicity within a millisecond.
For a new application with no UUIDv1 compatibility story, UUIDv7 is often easier to explain because Unix time is familiar and there is no UUIDv1-style node field.
For a system whose architecture intentionally depends on UUIDv1-style semantics, UUIDv6 offers a much more direct evolution path.
The question is not “which version number is newer?” It is “which identifier model matches the system?”
UUIDv6 is not a security token
Do not treat a UUIDv6 value as a secret.
Its time-oriented structure means it is intentionally not an opaque block of uniformly random bits. Its node field may also reveal information the application did not intend to use as authorization material.
This is wrong:
# Possession of an identifier must not be the authorization check.
def download_invoice(invoice_id: str):
...The endpoint still needs normal authentication and authorization.
If an application needs an unguessable security token, generate a token specifically for that security purpose with a cryptographically secure mechanism.
Identifiers and credentials should remain separate concepts.
Preserve UUIDs as UUIDs in the database
If the database has a native UUID type, prefer it over storing textual UUIDs in an arbitrary string column.
For example, an application model can keep the value typed as a UUID all the way to the persistence boundary:
from dataclasses import dataclass
from datetime import datetime
import uuid
@dataclass(frozen=True)
class Order:
id: uuid.UUID
created_at: datetimeThen create new records with:
from datetime import datetime, timezone
import uuid
order = Order(
id=uuid.uuid6(),
created_at=datetime.now(timezone.utc),
)This keeps formatting concerns out of domain code and avoids accidental differences between canonical UUID text, uppercase text, brace-wrapped forms, and raw bytes.
Verify how your database actually orders UUIDs
“UUIDv6 improves database locality” does not mean every database, schema, ORM, and storage representation orders UUIDs identically.
Before migrating a production key strategy, verify the exact representation.
Questions worth answering include:
- Is the column a native UUID type, binary value, or text?
- How does the database compare that type?
- Does the ORM transform UUID byte order?
- Is the UUID the clustered or primary index key?
- Are secondary indexes storing the primary key alongside their entries?
- Does the storage engine’s page behavior make insert locality important for this workload?
A format-level improvement can be neutralized by an application that rearranges bytes or stores identifiers in a surprising textual representation.
Test the real stack.
Do not rewrite historical UUIDv1 primary keys casually
Suppose an existing table already contains UUIDv1 keys.
A migration to UUIDv6 generation does not require rewriting old identifiers.
In many systems the safest migration is simply:
- preserve every existing UUIDv1 value;
- allow the column and application parser to accept UUIDs generally;
- generate UUIDv6 only for newly created records;
- record the accepted versions in tests and API documentation.
A UUID column can contain values of different UUID versions. The version is encoded in the UUID itself.
Re-keying old rows can be expensive because identifiers often appear in foreign keys, URLs, logs, caches, messages, backups, analytics systems, and external integrations.
Changing the generator for new rows is usually much less disruptive than changing identity for old rows.
Validate version only where the contract requires it
A parser at a UUIDv6-specific boundary can enforce the version:
import uuid
def parse_uuid6(text: str) -> uuid.UUID:
try:
value = uuid.UUID(text)
except (ValueError, AttributeError) as exc:
raise ValueError("invalid UUID") from exc
if value.version != 6:
raise ValueError("expected UUIDv6")
return valueBut do not add this check blindly to an endpoint that must continue accepting historical UUIDv1 records.
For a mixed-version table, a more appropriate boundary may simply be:
import uuid
def parse_record_id(text: str) -> uuid.UUID:
try:
return uuid.UUID(text)
except (ValueError, AttributeError) as exc:
raise ValueError("invalid record identifier") from excValidation should follow the data contract, not the generator currently used for new records.
Roll out generation separately from parsing
A robust migration often has two distinct compatibility directions.
Readers should become tolerant before writers change.
A practical deployment sequence is:
release A: all services can read UUIDv1 and UUIDv6
release B: selected writers begin producing UUIDv6
release C: all writers produce UUIDv6This matters in distributed deployments where old and new application versions overlap for minutes, hours, or days.
If a new writer emits UUIDv6 before an old consumer can handle it, the migration can fail even though both versions are valid UUIDs.
The same rule applies to serializers, validation schemas, ETL jobs, data warehouses, and client SDKs.
Check version support at process startup
uuid.uuid6() is new in Python 3.14.
If the application supports older Python releases, isolate the compatibility decision:
import uuid
def new_record_id() -> uuid.UUID:
generator = getattr(uuid, "uuid6", None)
if generator is None:
raise RuntimeError("UUIDv6 generation requires Python 3.14+")
return generator()For a service that requires UUIDv6 as part of its persistence contract, failing at startup is often better than silently falling back to another UUID version.
A fallback changes identifier semantics.
This is dangerous:
# Avoid semantic fallback unless it is an explicit contract.
generator = getattr(uuid, "uuid6", uuid.uuid4)The application may appear healthy while producing a completely different key distribution than operators expect.
Centralize identifier generation
Even when the API is a one-line call, wrap the application’s policy in one place:
import uuid
def new_order_id() -> uuid.UUID:
return uuid.uuid6()That wrapper provides a useful boundary for:
- compatibility checks;
- metrics;
- deterministic tests at the application layer;
- future identifier migrations;
- and documentation of why UUIDv6 was selected.
It also prevents individual call sites from inventing their own node or clock_seq policies.
Do not mock the UUID algorithm itself
Application tests usually care that a component receives an identifier, persists it, and returns it correctly.
Inject an ID factory when deterministic values are useful:
from collections.abc import Callable
import uuid
class OrderService:
def __init__(self, id_factory: Callable[[], uuid.UUID] = uuid.uuid6):
self._id_factory = id_factory
def create_id(self) -> uuid.UUID:
return self._id_factory()A test can then provide a fixed UUID:
fixed = uuid.UUID("1f000000-0000-6000-8000-000000000001")
service = OrderService(id_factory=lambda: fixed)
assert service.create_id() == fixedSeparately, a small integration test can verify that the production factory really returns version 6:
value = uuid.uuid6()
assert value.version == 6Do not build tests around exact generated values or exact timing relationships that the API does not promise.
Test ordering claims at the correct level
If UUIDv6 was selected to improve database insertion locality, a unit test comparing two freshly generated Python objects proves very little.
The important test is closer to the database:
- generate a representative batch of identifiers;
- insert them through the real persistence layer;
- inspect the database’s ordering and index behavior;
- compare the workload against the previous key strategy;
- measure page splits, write amplification, latency, or other engine-specific signals that motivated the change.
Performance architecture should be validated with workload evidence.
The UUID standard defines the format. It does not define your database engine’s performance.
Mixed UUID versions affect naive sorting
A table can safely contain UUIDv1 and UUIDv6 values, but sorting the entire mixed set by raw UUID value does not magically produce one unified historical timeline.
The two versions have different layouts.
So this query should not be treated as a chronological API merely because the IDs are time-based:
SELECT *
FROM orders
ORDER BY id;If chronological results matter, use the explicit timestamp:
SELECT *
FROM orders
ORDER BY created_at, id;The secondary id key can provide deterministic tie-breaking without pretending that UUID order defines business time.
Watch external validators
Some systems validate UUIDs more narrowly than they should.
A regular expression might accidentally allow only version 1 or version 4:
...-[14]...-...A schema library, API gateway, database extension, client SDK, or analytics pipeline may have a similar assumption.
Before enabling UUIDv6 generation, search the whole data path for version-specific validation.
This is one reason the read-before-write rollout is valuable: it exposes consumers that treated “UUID” as shorthand for a smaller set of historical versions.
Serialization usually does not need to change
The canonical textual representation remains the familiar hyphenated UUID form:
import uuid
value = uuid.uuid6()
encoded = str(value)
decoded = uuid.UUID(encoded)
assert decoded == valueJSON APIs can therefore continue serializing UUIDv6 as a string if that is already their convention.
The important compatibility question is whether downstream validators accept the version, not whether the UUID suddenly needs a new textual envelope.
Decide what logs should expose
Time-oriented identifiers are convenient for tracing because the same ID can appear in application logs, database rows, queue messages, and API responses.
But UUIDv6 also carries structural information.
Logging policy should therefore follow the same data-classification rules as other identifiers. Do not assume that “it is just a UUID” makes it harmless in every environment.
In particular, think about the node field before copying production identifiers into public issue trackers, examples, or documentation.
Use synthetic values in published material.
A migration checklist
Before switching an existing UUIDv1 system to UUIDv6 generation, I would verify all of these points:
- The production runtime is Python 3.14 or newer.
- Every reader accepts UUIDv6 before any writer emits it.
- The database’s UUID ordering and physical storage behavior are understood.
- Existing UUIDv1 rows remain valid and do not need re-keying.
- Business chronology uses an explicit timestamp rather than UUID ordering.
- Version-specific regexes and schema validators have been audited.
- Node-identifier privacy implications are acceptable.
- UUIDs are not being used as authorization secrets.
- Performance claims have been measured on the real persistence stack.
- Rollback behavior is defined if UUIDv6 generation must be stopped.
That last point is easy to miss. A rollback does not mean deleting UUIDv6 rows. Once identifiers have been issued, readers should continue accepting them even if writers temporarily return to the previous generator.
The design principle
UUIDv6 is best understood as an engineering improvement for a specific family of time-based identifiers.
It preserves the UUIDv1-style model while arranging its timestamp to work better with ordinary UUID ordering and database locality. Python 3.14 makes that format available without a third-party dependency.
The useful migration is therefore not “replace every UUID with version 6.”
It is narrower:
- keep UUIDv1 history intact;
- make consumers version-tolerant;
- use UUIDv6 for new values when UUIDv1 semantics are still desirable;
- keep explicit timestamps for application chronology;
- and validate the storage benefit in the database that actually runs the workload.
That approach gets the locality advantage without quietly turning an identifier format into a clock, a credential, or a migration project much larger than it needs to be.