Plate 29
base64 vs hex Encode/Decode Throughput Lab
Hands-on base64 vs hex encode/decode lab on 32MiB buffers with real MB/s measurements in both directions on localhost.
Aditya Challa6 min read
On this page
- Intro — what this post promises
- What we compared
- Lab topology
- Lead table — rebench n=15 (p50)
- First-pass vs warm (honest)
- How to read these numbers
- Pitfalls we hit (or avoided)
- Practical checklist
- Methodology footnote
- Versions pinned
- Size vs speed — a worked example
- When base64 still wins product-wise
- Append: encode RPS view
- Related reading on this blog
- Verdict
Intro — what this post promises
APIs love base64. Debuggers love hex. Both inflate payloads and burn CPU. How fast is each direction in CPython, and what do you pay in size?
This is a hands-on lab with measured numbers:
- Encode 32 MiB
os.urandomwithbase64.b64encode,binascii.b2a_base64,binascii.hexlify,bytes.hex. - Decode the matching forms (
b64decode,unhexlify,bytes.fromhex, …). - Expansion ratios (1.333× vs 2.0×).
- Warm rebench after a colder first pass.
Related links:
- SHA-256 vs BLAKE2b vs xxHash localhost lab
- json vs orjson vs msgpack localhost lab
- zstd vs gzip vs lz4 compression localhost lab
- fsync vs fdatasync localhost lab
- Why your average latency graph is lying (p50 / p95 / p99)
Lab honesty (1 Oct 2026 IST): Shared Linux lab box (8 cores). Python 3.13.5. Affiliates: 0. MB/s uses raw bytes as the denominator for both encode and decode so codecs compare fairly.
Verdict up front: warmed encode is a dead heat — hexlify ~668 MB/s vs b64encode ~645 MB/s. Decode is where hex wins hard (unhexlify ~881 vs b64decode ~388 MB/s). Hex still costs 2.0× size vs base64 1.333×.
What we compared
| API | Direction | Output |
|---|---|---|
base64.b64encode / b64decode | both | bytes, 6-bit alphabet |
binascii.b2a_base64 / a2b_base64 | both | same idea, newline=False |
binascii.hexlify / unhexlify | both | bytes of ASCII hex |
bytes.hex / bytes.fromhex | both | str hex ↔ bytes |
Related links:
Lab topology
Script: lab-evidence/40-base64-vs-hex/results/run_lab.py.
Lead table — rebench n=15 (p50)
| Arm | MB/s | p50 |
|---|---|---|
base64.b64encode | 645 | 49.6 ms |
binascii.b2a_base64 | 646 | 49.6 ms |
binascii.hexlify | 668 | 47.9 ms |
bytes.hex | 668 | 47.9 ms |
base64.b64decode | 388 | 82.5 ms |
binascii.unhexlify | 881 | 36.3 ms |
bytes.fromhex | 712 | 44.9 ms |
Encoded sizes on this buffer: base64 44 739 244 bytes (1.333×); hex 67 108 864 (2.000×).
Related links:
First-pass vs warm (honest)
The initial matrix saw b64encode at ~469 MB/s before later stability settled ~626–635 MB/s and rebench ~645 MB/s. Hexlify stayed ~640–670 across passes. Quote the warmed encode numbers; mention the cold first sample so nobody thinks we hid variance.
Other first-pass notes: b64encode→str via .decode("ascii") fell to ~340 MB/s; urlsafe_b64encode ~331 MB/s encode / ~247 MB/s decode on that pass — URL-safe is not free.
How to read these numbers
- Wire size: base64 wins (4/3 vs 2×).
- CPU decode: hex/
unhexlifywon big here. - CPU encode: essentially tied once warm.
bytes.hex≈hexlifyfor speed; type differs (strvsbytes).
Related links:
- json vs orjson vs msgpack localhost lab
- zstd vs gzip vs lz4 compression localhost lab
- SQLite WAL vs DELETE journal localhost lab
Pitfalls we hit (or avoided)
- Denominator games — always MB/s on raw length.
- One cold base64 sample — rebench/stability required.
- Forgetting expansion when declaring hex “faster.”
- URL-safe / str decode paths adding tax.
- Compressing afterward — hex+zstd may still lose to base64+zstd; measure (compression lab).
Practical checklist
- Prefer base64 (or raw binary) on the wire when size matters.
- Prefer hex for logs/debug when humans read dumps.
- Avoid encode→
strextras unless required. - Benchmark both directions on your payload class.
- Pair with compression if blobs are large.
Bottom line: hex is not “slow base64” on this box — it is slightly faster to encode when warm and much faster to decode, at a 50% larger ASCII footprint than base64’s 33% overhead.
Related links:
- fsync vs fdatasync localhost lab
- posix fadvise sequential vs random localhost lab
- python re vs str methods localhost lab
- uuid4 vs secrets token localhost lab
Methodology footnote
Input: os.urandom(32 MiB). Timed with time.perf_counter. Decode arms assert equality to the original buffer. Expansion recorded from encoded lengths. Rebench used 3 warmups + 15 timed calls for the headline table.
Versions pinned
- Python 3.13.5 stdlib
base64/binascii - Buffer 32 MiB (lab box MemAvailable was tight)
Size vs speed — a worked example
Take 32 MiB of raw API payloads you insist on ASCII-armoring:
- Base64 on the wire ≈ 42.7 MiB (1.333×) at ~645 MB/s encode → ~50 ms CPU on this box.
- Hex on the wire ≈ 64 MiB (2.0×) at ~668 MB/s encode → ~48 ms CPU.
Encode CPU is a wash; you just shipped ~21 MiB extra with hex. On decode, hex’s ~881 MB/s vs base64’s ~388 MB/s saves wall time if decode-bound — still check whether bandwidth or storage dominates. If you gzip/zstd afterward, re-bench the pair (hex often compresses differently than base64’s restricted alphabet).
When base64 still wins product-wise
JSON APIs, data URLs, and PEM-adjacent ecosystems expect base64. Hex shines in hexdumps, digests display, and some log pipelines. Do not switch encodings for a 3 MB/s encode delta; switch for ecosystem fit and size, then confirm decode path cost.
Append: encode RPS view
For a fixed 32 MiB buffer, encode p50 times imply whole-buffer rates:
| Codec | p50 | buffers/s |
|---|---|---|
| b64encode | 49.6 ms | ~20.2 |
| hexlify | 47.9 ms | ~20.9 |
Per-megabyte, that is the MB/s table again — useful when capacity planning says “N bulk exports/minute.” If your objects are 2 KiB JSON blobs instead of 32 MiB, fixed overheads loom larger; microbench large buffers to stress the codec, then confirm on production sizes (same advice as the orjson lab).
Also recorded on the first pass: binascii.b2a_hex 668 MB/s (alias family of hexlify) and standard_b64encode matching b64encode. Prefer the readable API; optimize only if profiles say so.
If you stream encode in chunks, expect different absolute MB/s but similar rankings; this lab is whole-buffer for clarity.
Related reading on this blog
The hash and compression labs sit next to this one in a “bytes on the CPU” cluster: hash if you need integrity, compress if you need size, base64/hex if you need ASCII safety. Measure the pipeline you ship, not each stage in isolation only.
Verdict
Warmed hexlify (~668 MB/s) and b64encode (~645 MB/s) are peers on encode; unhexlify (~881 MB/s) crushes b64decode (~388 MB/s). Pay 2.0× bytes for hex vs 1.333× for base64. Pick the encoding for the bottleneck you actually have — size, decode CPU, or human readability.
Evidence path on the lab box: lab-evidence/40-base64-vs-hex/results/. Affiliates: 0.
Lab evidence
What I found running this
Lab 1 Oct 2026 IST. Python 3.13.5; 32 MiB os.urandom. Rebench n=15 encode: base64.b64encode ~645 MB/s; binascii.hexlify ~668; bytes.hex ~668; binascii.b2a_base64 ~646. Decode: b64decode ~388; unhexlify ~881; bytes.fromhex ~712. Expansion: base64 1.333x, hex 2.0x. First-pass b64 colder (~469) before warm stability ~630. Affiliates: 0. Evidence: lab-evidence/40-base64-vs-hex/.
Related links
Plate 56
json vs orjson vs msgpack: Python Serializer Lab
Hands-on Python json vs orjson vs msgpack lab: encode/decode MB/s on records, nested, and unicode payloads. Real p50 numbers measured on localhost box.
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