JSON, MessagePack and Protobuf Tested
TL;DR: I encoded the same 50,000 structured log records as JSON, MessagePack and Protobuf, five runs each, and the results cut against the usual advice. MessagePack only reaches 80.9% of the JSON size and Protobuf reaches 51.4%, but after one pass of gzip all three land in the same band, with MessagePack actually 2.1% *larger* than gzipped JSON. What really differs is speed and schema evolution, and those two rarely appear in the selection debate.
Most people deciding whether to move off JSON are working from a bar chart in somebody else's blog post. Those charts tend to fold "fast" and "small" into a single axis and rarely tell you what the payload actually looked like. So I ran one dataset through every candidate on this machine and wrote down the numbers together with the comparison logic, so you can reproduce it.
Test environment and method
Every number below was produced on this machine on 2026-10-25:
- OS and runtime: Windows 11, Python 3.13.12
- msgpack 1.2.3, orjson 3.13.0, msgspec 0.22.0, protobuf 7.36.2 (latest releases from PyPI on that date)
- Data: random seed pinned to 20261025, 50,000 log events, nine fields each (integer timestamp, string level, string service name, 32-character hex trace_id, integer status, float latency, integer byte count, boolean cache flag, string message)
- Timing: five runs per operation, the fastest run is reported, so background noise does not inflate any gap
- Protobuf is used without protoc: to keep the script copy-pasteable I build the schema at runtime from
descriptor_pb2
That last part is the only unusual piece, so here it is up front:
from google.protobuf import descriptor_pb2, descriptor_pool, message_factory
T = descriptor_pb2.FieldDescriptorProto
fp = descriptor_pb2.FileDescriptorProto()
fp.name = "bench.proto"
fp.syntax = "proto3"
msg = fp.message_type.add()
msg.name = "Event"
for name, num, typ in [("ts", 1, T.TYPE_INT64), ("level", 2, T.TYPE_STRING)]:
f = msg.field.add()
f.name, f.number, f.type, f.json_name = name, num, typ, name
f.label = T.LABEL_OPTIONAL
Desc = descriptor_pool.Default().Add(fp)
Event = message_factory.GetMessageClass(Desc.message_types_by_name["Event"])
1. Size: what you save is mostly the field names
Full encode of the 50,000 records, in bytes:
| Encoding | Bytes | vs compact JSON | After gzip level 6 | Share after gzip |
|---|---|---|---|---|
| JSON (compact separators) | 10,294,992 | 100.0% | 2,659,489 | 25.8% |
| JSON (default separators) | 11,194,996 | 108.7% | — | — |
| orjson | 10,294,992 | 100.0% | — | — |
| msgspec.json | 10,294,992 | 100.0% | — | — |
| MessagePack (msgpack-python) | 8,330,309 | 80.9% | 2,715,571 | 26.4% |
| MessagePack (msgspec) | 8,330,309 | 80.9% | — | — |
| Protobuf | 5,294,403 | 51.4% | 2,657,929 | 25.8% |
| pickle protocol 5 | 5,693,833 | 55.3% | — | — |
Three things here get conflated constantly.
First, orjson and msgspec.json produce byte-identical output to the standard library: 10,294,992 bytes in all three cases. They sell speed, not size. If you switched to them to save space, you picked the wrong tool.
Second, MessagePack only gets to 80.9%. It has no schema, so every record has to write all nine field names again. I re-encoded the same rows from a map into a positional array — still MessagePack — and the size dropped from 8,330,271 to 5,080,271 bytes, exactly 3,250,000 bytes saved. Per record: of 164 bytes, 66 bytes are key names and their type headers, 40.2% of the total. So nearly all of MessagePack's 19.1% win over JSON is field-name compression, not data compression.
Third, Protobuf reaches 51.4% because what goes on the wire is a field number, not a field name. There is no key-name overhead at all. The price is equally direct: without the .proto file, nobody can read those bytes.
2. After compression, how much of the advantage is left
This is the most counter-intuitive result of the run. I pushed all three encodings through gzip at level 6:
gzip(JSON)= 2,659,489 bytes, 25.8% of the original JSONgzip(MessagePack)= 2,715,571 bytes, 26.4% of the original JSONgzip(Protobuf)= 2,657,929 bytes, 25.8% of the original JSON
Note the spread: 25.8% to 26.4%, a band 0.6 percentage points wide — and gzip(MessagePack) is 56,082 bytes, or 2.1%, *larger* than gzip(JSON).
The mechanism is not hard to see. Repeated field names are precisely what dictionary-based compressors are best at. MessagePack removes that redundancy by hand, which is doing up front the work gzip was going to do anyway. You change the order of operations, not the outcome.
The decision signal is clear: if your bottleneck is disk or bandwidth, the first move is to turn on compression, not to change wire format. The returns from changing format show up in CPU and latency. In my runs, handing JSON straight to gzip got within 0.06 percentage points of Protobuf, while keeping everything readable, greppable and diffable.
3. Speed: a much wider gap than size
Encoding time (whole 50,000-record payload, fastest of five):
| Encoder | Time |
|---|---|
| json.dumps | 118.44 ms |
| msgpack.packb | 68.90 ms |
| pickle.dumps | 72.35 ms |
| msgspec.json.encode | 38.24 ms |
| msgspec.msgpack.encode | 34.15 ms |
| orjson.dumps | 26.86 ms |
| protobuf SerializeToString | 20.24 ms |
Decoding time (same payload):
| Decoder | Time |
|---|---|
| json.loads | 200.94 ms |
| msgpack.unpackb | 179.29 ms |
| msgspec.json.decode | 121.07 ms |
| orjson.loads | 113.68 ms |
| msgspec.msgpack.decode | 75.39 ms |
| pickle.loads | 59.47 ms |
| protobuf ParseFromString | 16.51 ms |
Three observations:
1. orjson is 4.4x faster than stdlib json (26.86 ms vs 118.44 ms) and emits identical bytes. It is the only zero-cost upgrade in the whole table — one import line.
2. Two libraries, same MessagePack, 2.4x apart (msgspec 75.39 ms vs msgpack-python 179.29 ms). When you pick a binary format, which implementation you pick matters at least as much as which format.
3. Protobuf is fastest at both ends (20.24 ms and 16.51 ms). On the decode side that is a 12.2x gap against stdlib JSON, which is large enough to move a service's tail latency.
4. What you are really choosing is schema evolution
Size you can measure today. Schema evolution bites six months later. I ran both.
4.1 Protobuf's default-value elision is a feature and a trap
The official Protobuf guide, in the Specifying Field Cardinality section, says this about scalar fields written without optional:
the field is set to the default (zero) value. It will not be serialized to the wire. In fact, you cannot determine whether the default (zero) value was set or parsed from the wire or not provided at all.
The same point is restated in Default Field Values:
> Note that for implicit-presence scalar fields, once a message is parsed there's no way of telling whether that field was explicitly set to the default value (for example whether a boolean was set to false) or just not set at all
I verified it byte by byte with a message holding id, status, name and cached:
- Nothing set at all → 0 bytes
- Explicitly set to
status=0,name="",cached=False→ 0 bytes, byte-identical to the previous case - Only
name="hello"→1a0568656c6c6f, 7 bytes - With the field declared
optional, explicitly settingstatus=0→1000, 2 bytes, andHasField("status")returnsTrue; not setting it → 0 bytes andHasFieldreturnsFalse
The effect scales: forcing status to 0 across all 50,000 records dropped the Protobuf payload from 5,294,391 to 5,144,391 bytes, a 2.8% reduction that works out to exactly 3 bytes per record. Leave every field but the timestamp at its default and the whole payload is 400,007 bytes.
Both sides of this behaviour have to be read together: it saves bytes and it permanently erases the difference between "set to 0" and "not set". If your protocol has a scalar where 0 carries meaning (quota=0 meaning explicitly disabled rather than unconfigured), you need optional, because otherwise that meaning does not survive the wire.
4.2 Unknown fields: forwarding is safe, with one hard edge
From the official Unknown Fields section:
Proto3 messages preserve unknown fields and include them during parsing and in the serialized output, which matches proto2 behavior.
I tested it: the newer schema writes trace_id and produces 17 bytes, 080712037376631a086162636431323334. Parsing that with an older schema that lacks the field gives id=7 and name='svc', and re-serializing produces output byte-identical to the original. Older forwarding services can pass new data through untouched — a property that matters a lot for HTTP gateways and queue consumers.
But the same section's Retaining Unknown Fields list then names the cases where you lose them, and one of them is:
Serialize a proto to JSON.
So the same service is safe passing binary through and unsafe the moment some hop converts to JSON. That boundary belongs in the design doc, because it only shows up when you convert.
One more that I found the hard way: change field number 3 from string to int32 and parse old data with the new schema — no error, you get 0. Once a field number is assigned its type is frozen. That is a constraint, not a recommendation.
4.3 MessagePack: quiet enough that you assume it is fine
MessagePack has no schema, so there are no evolution rules either. Same field, integer versus string:
{"id": 1, "amount": 100}→ 13 bytes{"id": 1, "amount": "100"}→ 16 bytes
The consumer receives 100 and '100' respectively, with no error at any point. Missing and extra keys are equally quiet: {"id": 1} is 5 bytes, adding currency takes it to 26, and downstream code is left to guess from the data.
The fix is a type contract on the consumer side. msgspec's Struct gives you that boundary, and the official supported-types page puts it like this:
During decoding, any unknown fields are ignored (this can be disabled, see forbid-unknown-fields), and any missing optional fields have their default values applied. An error is raised during decoding if the type doesn't match or if any required fields are missing.
Note the default — unknown fields are ignored. A typo is not detected, and combined with an optional field that has a default, it silently degrades to that default. I checked both forms on this machine; the difference is a single class argument:
class Ev(msgspec.Struct):
id: int
amount: int
msgspec.msgpack.decode(byte_string, type=Ev)
# default: Ev(id=1, amount=100) — currency silently dropped
class StrictEv(msgspec.Struct, forbid_unknown_fields=True):
id: int
amount: int
# with the flag: msgspec.ValidationError: Object contains unknown field `currency`
5. Troubleshooting: six errors I reproduced locally
Every message below was produced on this machine and copied verbatim.
Error one: `TypeError: can not serialize 'datetime.datetime' object`
>>> msgpack.packb({"ts": datetime.datetime(2026, 10, 25, 6, 10)}, use_bin_type=True)
TypeError: can not serialize 'datetime.datetime' object
The MessagePack spec has no timestamp type of its own; it has to travel as an Ext extension. Adding datetime=True does not rescue it either — you get a different message:
ValueError: can not serialize 'datetime.datetime' object where tzinfo=None
because a naive datetime has no determinable time zone. Either attach a tzinfo before using datetime=True, or convert to a string or Unix timestamp yourself. Do not assume orjson's automatic ISO conversion exists elsewhere. In my comparison orjson emitted b'{"ts":"2026-10-25T06:10:00"}' and msgspec.json did the same; only msgpack requires your involvement.
Error two: `TypeError: Dict key must be str`
>>> orjson.dumps({1: "one", 2: "two"})
TypeError: Dict key must be str
The orjson README states that without OPT_NON_STR_KEYS, dumps() raises JSONEncodeError, a subclass of TypeError. The fix is orjson.dumps(d, option=orjson.OPT_NON_STR_KEYS).
What makes this one worth attention is the standard library's contrasting behaviour: json.dumps({1: "one"}) raises nothing, emits {"1": "one"}, and gives you str keys back on the way in. Same input, one library silently retypes and another raises — that divergence is exactly the kind of bug that is hardest to find across a service boundary.
Error three: `ValueError: int is not allowed for map key when strict_map_key=True`
>>> msgpack.packb({1: "one"}, use_bin_type=True)
ValueError: int is not allowed for map key when strict_map_key=True
msgpack-python enables strict_map_key by default. The JSON family either retypes silently or raises; MessagePack refuses outright. Three libraries, three different opinions — the cheap design rule is no non-string dict keys in any data that crosses formats.
Error four: one large integer, three overflow errors
The same value, 2^70 (1,180,591,620,717,411,303,424), handed to four libraries:
json.dumps→ fine, round-trips exactlyorjson.dumps→TypeError: Integer exceeds 64-bit rangemsgpack.packb→OverflowError: Integer value out of range- protobuf
int64field →ValueError: Value out of range: 1180591620717411303424
Stdlib JSON carrying arbitrary-precision integers is a hazard more than a benefit. It looks perfect on the Python side, and then a JavaScript consumer reads it as a Number and 2^53+1 becomes 9007199254740992. If data crosses languages, large IDs should be strings. That holds for all four libraries.
Error five: `msgspec.ValidationError: Expected int, got str`
>>> msgspec.msgpack.decode(byte_string, type=Ev)
msgspec.ValidationError: Expected `int`, got `str` - at `$.amount`
This one is good news: it turns the type drift that MessagePack would otherwise swallow into an explicit failure, and the message carries a JSON Pointer path, $.amount. A missing field produces a different one:
msgspec.ValidationError: Object missing required field `amount`
Error six: `orjson.JSONDecodeError` meets non-JSON from the standard library
>>> import json, orjson
>>> json.dumps({"v": float("nan")})
'{"v": NaN}'
>>> orjson.loads(json.dumps({"v": float("nan")}))
orjson.JSONDecodeError: unexpected character, expected a JSON value: line 1 column 7 (char 6)
Stdlib json emits NaN and Infinity as bare literals by default, and that is not valid JSON — strict parsers reject it. orjson and msgspec.json go the other way and quietly serialize them as null (orjson's README: serializes Nan, Infinity, and -Infinity, which are not compliant JSON, as null). MessagePack keeps nan as-is.
Three behaviours, two failure directions: produce bad data, or silently lose data. In production, set allow_nan=False explicitly or validate floats, and close this off before it ships.
6. When you should not switch to a binary format
Given all of the above, these are the cases where I would stay put:
1. The bottleneck is disk or bandwidth — reach for gzip(JSON) instead. Measured at 25.8%, only 0.06 points behind gzip(Protobuf), with readability intact.
2. Humans read this data — configuration files, log pipelines, audit records. This outranks every performance argument.
3. More than two languages and no central schema registry — Protobuf needs .proto distribution, MessagePack needs a contract mechanism of your own. Without those, a binary format buys you "runs fine today, unchangeable in six months".
4. The data gets grepped, diffed or code-reviewed — binary rules out all three at once.
5. Nobody has committed to never reusing field numbers — without that commitment you do not actually get Protobuf's compatibility guarantee.
The signals that do justify a switch are the mirror image: small messages at very high QPS where CPU cost multiplies, high fan-out calls between services where latency compounds, and a schema governance process that already exists — in which case you should use it.
Limits of this test
So nothing here gets misread, here is what I did not do:
- Everything ran single-process and serial on Windows 11. No concurrency was measured, and no real network stack cost is included
- The data is synthetic logs with a fixed field mix; deeply nested structures or many small messages will move the numbers
- Avro, Thrift, FlatBuffers and Cap'n Proto are not covered, and neither are Thrift's two protocol layers
- Only gzip level 6 was tested; zstd, lz4 and brotli are out of scope for this article
- Protobuf ran on the pure-Python runtime (protobuf 7.36.2) with the upb C extension disabled, so its numbers are conservative — production can go faster
- Version figures are current as of 2026-10-25
No affiliate links appear in this article, and no commissions are earned from it.
Related reading
If what you care about is making files smaller on disk, that is a different layer — I measured the Parquet defaults in "Parquet and Arrow Tested: Default Dictionary Encoding Adds 15% to the File". That one is a columnar file format on disk; this one is a wire format on the network. To then query that local data without a database server, "DuckDB 1.5.6 Tested: 40 MB CSV Without a Database" is the natural next step. And when you finally run this inside a long-lived service, the logging and observability layer has its own set of traps, which I worked through in "Python Logging Tested: 6 Real Errors and Lost Records".
👉 Join Xiaomi MiMo Platform: Leading AI model platform with cost-effective inference
👉 Join Aliyun AI: Top AI products with exclusive coupons for business innovation
📌 This article was AI-assisted generated and human-reviewed | TechPassive — An AI-driven content testing site focused on real tool reviews
🔗 Recommended Tools
These are carefully selected tools. Using our affiliate links supports us to keep producing quality content: