Plate 40
uuid4 vs secrets Token Generation Rate Lab
Hands-on uuid.uuid4 vs secrets.token_hex/urlsafe vs os.urandom ops/s on localhost. Throughput only; uniqueness and collisions not measured in this lab.
Aditya Challa6 min read
On this page
- Intro — what this post promises
- What each call gives you
- Lab topology
- Lead table — p50 ops/s
- Stability notes
- How to read these numbers
- Pitfalls we hit (or avoided)
- Practical checklist
- Methodology footnote
- Versions pinned
- Formatting is part of the API
- What we deliberately did not do
- Append: entropy size checklist
- Related reading on this blog
- Verdict
Intro — what this post promises
Session IDs, CSRF tokens, and job keys all call some RNG wrapper. uuid.uuid4() is ubiquitous; secrets is the stdlib module meant for security tokens; os.urandom is the underlying entropy source. How many can you mint per second?
This is a hands-on lab with measured numbers:
- Ops/s for
uuid4, string/hex/bytes forms. secrets.token_bytes/token_hex/token_urlsafeat 16 and 32 bytes.- Raw
os.urandomfor comparison. uuid1as a labeled non-recommendation.
Uniqueness / collisions are not measured — throughput only.
Related links:
- SHA-256 vs BLAKE2b vs xxHash localhost lab
- base64 vs hex encode localhost lab
- json vs orjson vs msgpack localhost lab
- fork COW RSS vs spawn localhost lab
- Why your average latency graph is lying (p50 / p95 / p99)
Lab honesty (1 Oct 2026 IST): Shared Linux lab box. Python 3.13.5. Affiliates: 0. This is not a cryptographic proof and not a birthday-bound study.
Verdict up front: uuid4 ~972k ops/s; secrets.token_hex(16) ~4.29M (4.4×); 8.3×). Formatting (UUID object → str) has a real tax.os.urandom(16) ~8.06M (
What each call gives you
| Call | Typical product | Notes |
|---|---|---|
uuid.uuid4() | UUID object | RFC 4122 v4 |
str(uuid4()) / .hex | text forms | extra formatting |
secrets.token_bytes(n) | raw bytes | CSPRNG wrapper |
secrets.token_hex(n) | 2n hex chars | convenient |
secrets.token_urlsafe(n) | base64url text | web-safe |
os.urandom(n) | raw bytes | lowest wrapper |
uuid.uuid1() | time/node UUID | not for secrets |
Related links:
Lab topology
Script: lab-evidence/41-uuid4-vs-secrets/results/run_lab.py.
Lead table — p50 ops/s
| Arm | ops/s | ns/op | vs uuid4 |
|---|---|---|---|
uuid.uuid4() | 972 233 | 1029 | 1.00× |
uuid4().bytes | 912 121 | 1096 | 0.94× |
uuid4().hex | 687 732 | 1454 | 0.71× |
str(uuid4()) | 554 568 | 1803 | 0.57× |
secrets.token_urlsafe(16) | 2 097 648 | 477 | 2.16× |
secrets.token_hex(16) | 4 291 700 | 233 | 4.41× |
secrets.token_bytes(16) | 5 684 152 | 176 | 5.85× |
os.urandom(16) | 8 058 339 | 124 | 8.29× |
uuid.uuid1() | 219 553 | 4555 | 0.23× |
32-byte arms (100k batches): token_bytes ~4.52M, token_hex ~3.54M, token_urlsafe ~1.86M, os.urandom ~6.14M ops/s.
Related links:
Stability notes
uuid4 stability ~954k–990k ops/s. token_hex(16) stability showed ~3.3–4.5M (noisier). os.urandom stability ~4.6–5.5M on shorter 100k batches vs ~8.1M primary — quote bands, lead with the primary 200k-op table, and avoid fake precision.
How to read these numbers
- Need a UUID type / RFC form →
uuid4(accept ~1 µs/op class). - Need a security token string →
secrets.token_urlsafeortoken_hex(faster than uuid4 here). - Need raw key material →
secrets.token_bytesoros.urandom. uuid1is slower and leakier — do not use for session secrets.- Throughput ≠ uniqueness proof.
Related links:
- base64 vs hex encode localhost lab
- json vs orjson vs msgpack localhost lab
- fork COW RSS vs spawn localhost lab
Pitfalls we hit (or avoided)
- Claiming collision rates from ops/s — explicitly out of scope.
- Using uuid1 for tokens because it “looks random.”
- Ignoring
str(uuid4())tax (~1.8× slower than bare uuid4). - Comparing 16-byte hex to 32-byte tokens without labeling entropy size.
- Single-run urandom spikes — stability bands matter under load.
Practical checklist
- Security-sensitive IDs: prefer
secretsmodule APIs. - Public UUID fields:
uuid4, store/transmit the form you need (object vs hex vs str). - Measure formatting cost if you mint millions/sec.
- Document token byte length (16 vs 32).
- Never treat this lab as a CSPRNG audit.
If you also base64/hex encode tokens for cookies, add that cost from the companion encode lab — token_urlsafe already includes a web-safe encode step (and still beat uuid4 here).
Related links:
- fsync vs fdatasync localhost lab
- SQLite WAL vs DELETE journal localhost lab
- python re vs str methods localhost lab
- openssl TLS full vs resume localhost lab
Methodology footnote
Each timed trial runs n_ops synchronous calls in a tight Python loop (200 000 default; 100 000 for 32-byte arms). Ops/s = n_ops / p50_seconds. Samples are retained only for repr length checks — not for uniqueness analysis.
Versions pinned
- Python 3.13.5 (
uuid,secrets,os.urandom) - Default entropy size 16 bytes unless labeled 32
Formatting is part of the API
Bare uuid4() returns a UUID object (~972k/s). Most apps immediately need text:
.hex→ ~688k/s (0.71×)str(...)→ ~555k/s (0.57×)
If your hot path mints string IDs, compare against secrets.token_hex(16) (4.3M/s) or 2.1M/s) on equal footing — same “string out” requirement. Conversely, if Postgres/token_urlsafe(16) (UUID columns accept binary/UUID types, skipping str() keeps uuid4 closer to its ceiling.
What we deliberately did not do
No multi-threaded minting, no /dev/random blocking tests, no chi-square on outputs, no distributed collision hunt. Under cgroup CPU throttling your absolute ops/s will move; the ranking (urandom/token_bytes ≫ token_hex ≫ uuid4 ≫ uuid1) is the portable lesson until re-measured.
Append: entropy size checklist
| API | Entropy bytes | Text length (approx) |
|---|---|---|
token_bytes(16) | 16 | n/a (binary) |
token_hex(16) | 16 | 32 hex chars |
token_urlsafe(16) | 16 | ~22 chars |
token_bytes(32) | 32 | binary |
uuid4 | 122 bits random (6 bits version/variant) | 36 chars with dashes |
Minting faster does not add bits. A 16-byte token at 4 M ops/s is still 128 bits of entropy per token — the lab only says you can ask the CSPRNG that often. For cookie headers, prefer token_urlsafe; for debugging dumps, token_hex; for key material, token_bytes / urandom.
Under a web worker minting IDs per request, even uuid4’s ~1 µs is rarely the p95 villain — but batch jobs creating tens of millions of rows should still pick the API that matches the column type and security story, not cargo-cult uuid4.
Related reading on this blog
Token minting often sits beside TLS session setup and DB inserts (openssl / SQLite WAL labs). If minting is fast and INSERT is slow, optimize the durable path first — this ops/s table only clears the RNG wrapper as a bottleneck.
Verdict
On this box, bare uuid4 (~972k ops/s) trails secrets.token_hex(16) (~4.29M) and os.urandom(16) (~8.06M) by several×, while str(uuid4()) (~555k) shows formatting cost. Use UUID when you need UUID; use secrets when you need tokens — and remember we did not measure collisions.
Evidence path on the lab box: lab-evidence/41-uuid4-vs-secrets/results/. Affiliates: 0.
Lab evidence
What I found running this
Lab 1 Oct 2026 IST. Python 3.13.5; 200k ops batches. uuid4 ~972k ops/s (~1029 ns); uuid4 str ~555k; secrets.token_bytes(16) ~5.68M (~5.8x); token_hex(16) ~4.29M (~4.4x); token_urlsafe(16) ~2.10M (~2.2x); os.urandom(16) ~8.06M (~8.3x). uuid1 ~220k (not a secrets pick). Uniqueness/collisions not measured. Affiliates: 0. Evidence: lab-evidence/41-uuid4-vs-secrets/.
Related links
Plate 09
secrets vs urandom vs getrandbits: Localhost Lab
Observability & SRE · 30 Sept 2026
Plate 17
platform vs os.uname Inventory: Localhost Lab
Hands-on platform.platform vs os.uname host inventory lab: real ops/s plus cache notes, measured on Linux localhost today in this hands-on lab for SREs.
1 Oct 2026
Plate 75
uuid.uuid4 vs uuid.uuid1: Localhost Lab
Hands-on uuid.uuid4 vs uuid.uuid1 ID generation lab: real ops/s plus version/node checks, measured on Linux localhost today in this hands-on lab for SREs.
1 Oct 2026