Run uuid.uuid1() and look at the last 12 characters. That is your machine's node value, the same number uuid.getnode() returns, and on a computer with a network card it is the card's MAC address. Paste that UUID into a bug report, a vendor log or a commit message and the address goes with it.
uuid4() is a fine default, and most scripts should keep it. The problems sit around the call: what some versions give away, how you check an ID you did not generate, and how you store it.
TL;DR
- A
uuid1ends in your node value (a MAC address on real hardware). Keep it out of logs, tickets and commits.uuid.UUID(s)validates and normalizes, but it accepts more than you expect. Check.versiontoo.- Store a UUID as a native
uuidtype or 16 bytes, and compareUUIDobjects toUUIDobjects. AUUIDnever equals its own string.
Which UUID to use: uuid4 by default, uuid7 to sort, uuid5 to repeat
If a colleague asked "which one do I use?", this is the answer.
-
uuid.uuid4()unless you have a reason not to. Random, unique, nothing to configure. -
uuid.uuid7()when IDs should sort by creation time, such as newest-first lists. It needs Python 3.14 or newer. On 3.13 and older, theuuid-utilspackage is a backport. -
uuid.uuid5(namespace, name)when the same input must always produce the same ID, for dedupe or idempotent imports. -
uuid.uuid1()only inside a closed legacy system. Next section.
uuid5 is a hash of a namespace and a name, so there is nothing random in it:
import uuid
a = uuid.uuid5(uuid.NAMESPACE_DNS, "example.com")
print(a)
print(uuid.uuid5(uuid.NAMESPACE_DNS, "example.com") == a)
print(uuid.uuid5(uuid.NAMESPACE_URL, "example.com"))
cfbff0d1-9375-5685-968c-48ce8b15ae17
True
a5cf6e8e-4cfa-5f31-a804-6de6d1245e26
Same namespace and name give the same ID on every run and every machine. Change the namespace and the ID changes. Re-import the same record twice and it lands on the same key.
uuid1 puts your node value in the string
A uuid1 is a timestamp, a clock sequence and a node field. Python fills the node with getnode(), the machine's hardware address when it can find one.
import uuid
u = uuid.uuid1()
print(u)
print(f"{uuid.getnode():012x}")
print(str(u).endswith(f"{uuid.getnode():012x}"))
6a83bb0e-d101-11f1-85da-5ad924d2a6d7
5ad924d2a6d7
True
Try this: run those three lines on your own laptop. If it has a real network card, compare the 12 characters with the address
ip link(Linux) orifconfig(macOS) prints.
Our demo machine is a good example of the caveat. Its node starts with 5a, and that byte has the locally-administered bit set, which marks a virtual or container interface and not a burned-in card address. When Python finds no MAC at all, it substitutes a random node with the multicast bit set. Either way the pattern holds: anything that reaches a public place inside a uuid1 is the machine's own identifier, real or virtual.
The timestamp part tells readers when you created the record, too. uuid1 exists for interop with legacy systems that expect timestamp-plus-node IDs. If that is not your situation, uuid4 leaks neither.
uuid7 sorts by creation time, and shows it
The front of a uuid7 is a millisecond timestamp, so IDs generated later sort later. In a database that means new rows land at the end of a primary-key index, while random v4 keys land anywhere in it. A newest-first list can sort on the ID itself. We checked 2000 consecutive calls on Python 3.14.8 and every ID was greater than the last.
import uuid
from datetime import datetime, timezone
ids = [uuid.uuid7() for _ in range(5)]
print(ids == sorted(ids))
print(ids[0])
print(datetime.fromtimestamp(ids[0].time / 1000, timezone.utc))
True
01a176c9-4130-709d-a20f-cfd7e6df0d72
2026-10-26 05:57:43.344000+00:00
The third line is the tradeoff. Anyone holding a uuid7 can read when it was made, so skip it for IDs in public URLs if creation time is sensitive.
uuid.UUID() validates, but it is more lenient than you think
The constructor raises ValueError: badly formed hexadecimal UUID string on garbage and normalizes real spellings to one value. Users paste braces from Windows registry exports and urn:uuid: prefixes from RDF and XML, and both parse.
import uuid
def parse_uuid(s):
try:
return uuid.UUID(s)
except (ValueError, TypeError, AttributeError):
return None
samples = [
"123e4567-e89b-12d3-a456-426614174000",
"{123e4567-e89b-12d3-a456-426614174000}",
"urn:uuid:123e4567-e89b-12d3-a456-426614174000",
"123e4567e89b12d3a456426614174000",
"1-2-3e4567e89b12d3a456426614174000",
"00000000-0000-0000-0000-000000000000",
"not-a-uuid",
None,
]
for s in samples:
u = parse_uuid(s)
print(u, u.version if u else "-")
123e4567-e89b-12d3-a456-426614174000 1
123e4567-e89b-12d3-a456-426614174000 1
123e4567-e89b-12d3-a456-426614174000 1
123e4567-e89b-12d3-a456-426614174000 1
123e4567-e89b-12d3-a456-426614174000 1
00000000-0000-0000-0000-000000000000 None
None -
None -
Three things to take from the output. The constructor strips braces, the urn:uuid: prefix and every dash before it counts digits, so even the stray-dash string parses. Non-strings raise TypeError or AttributeError, not ValueError, which is why the except lists all three. And the all-zero nil UUID is accepted, with no version.
It also does not check that you got the version you wanted. That perfectly valid ID is a version 1. If your system promises random IDs, guard with u is None or u.version != 4 and reject the rest.
Four output formats, four destinations
Every UUID has four common outputs, and each fits a different place.
import uuid
u = uuid.uuid4()
print(str(u))
print(u.hex)
print(u.bytes)
print(len(u.bytes), len(u.hex))
print(u.urn)
5a42d315-493b-4265-852c-cb41fc74fa71
5a42d315493b4265852ccb41fc74fa71
b'ZB\xd3\x15I;Be\x85,\xcbA\xfct\xfaq'
16 32
urn:uuid:5a42d315-493b-4265-852c-cb41fc74fa71
-
str(u): 36 characters, dashed. For logs, tickets, JSON and anything a human reads. -
u.hex: 32 characters, no dashes. For compact keys and filenames. -
u.bytes: 16 raw bytes. For binary columns and wire formats. -
u.urn:urn:uuid:plus the dashed form. For XML namespaces and RDF.
Store UUIDs as 16 bytes, and compare UUID to UUID
On PostgreSQL, use the native uuid type, a 128-bit value. That settles the column question, and it takes dashed strings as input. On MySQL or SQLite you choose: CHAR(36) or TEXT holds str(u) and stays readable in a console, while BINARY(16) or BLOB holds u.bytes. The binary form is 16 bytes against 36 for every key, and the key is stored in the index too.
Try this: run the round trip below, then change
u.bytestostr(u)in the insert and see what comes back from the read.
import sqlite3
import uuid
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE items (id BLOB PRIMARY KEY)")
u = uuid.uuid4()
db.execute("INSERT INTO items VALUES (?)", (u.bytes,))
raw = db.execute("SELECT id FROM items").fetchone()[0]
print(len(raw), len(str(u)))
print(uuid.UUID(bytes=raw) == u)
16 36
True
The bug that actually bites sits on the Python side. A UUID object never equals its own string, and Python raises no error when you compare them.
x = uuid.uuid4()
print(x == str(x))
print(uuid.UUID(str(x)) == x)
print(uuid.UUID(str(x)) == uuid.UUID(str(x).upper()))
False
True
True
Say a row comes back from the database as a UUID and the request carries a string. The == check never matches, and nothing tells you why. Run both sides through uuid.UUID() first. As the last line shows, it also makes upper and lower case compare equal.
Do this today
In an existing codebase, run grep -rn "uuid1(" . and look at every hit. Each one is either a deliberate legacy choice or a leak. Then check that each uuid.UUID( call sits inside an except that covers more than ValueError, and that no == compares an ID string to a UUID.
uuid4() stays the default. The work is in the three places around it: what the ID gives away, what you accept as valid, and what you store.
Sources: the Python uuid documentation and RFC 9562. All code ran on Python 3.14.8, and UUID values will differ on your machine.
Top comments (1)
Hi