← Back to Home

JSON, MessagePack and Protobuf Tested

SerializationMessagePackProtobuf

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:

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:

EncodingBytesvs compact JSONAfter gzip level 6Share after gzip
JSON (compact separators)10,294,992100.0%2,659,48925.8%
JSON (default separators)11,194,996108.7%——
orjson10,294,992100.0%——
msgspec.json10,294,992100.0%——
MessagePack (msgpack-python)8,330,30980.9%2,715,57126.4%
MessagePack (msgspec)8,330,30980.9%——
Protobuf5,294,40351.4%2,657,92925.8%
pickle protocol 55,693,83355.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:

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):

EncoderTime
json.dumps118.44 ms
msgpack.packb68.90 ms
pickle.dumps72.35 ms
msgspec.json.encode38.24 ms
msgspec.msgpack.encode34.15 ms
orjson.dumps26.86 ms
protobuf SerializeToString20.24 ms

Decoding time (same payload):

DecoderTime
json.loads200.94 ms
msgpack.unpackb179.29 ms
msgspec.json.decode121.07 ms
orjson.loads113.68 ms
msgspec.msgpack.decode75.39 ms
pickle.loads59.47 ms
protobuf ParseFromString16.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:

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:

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:

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:

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:

☁️ DigitalOcean Cloud ⚡ Vultr VPS 🤖 QoderWork CN (Refer & Earn) ☁️ Aliyun AI Products 📚 WordPress Books 🔍 WordPress SEO Books 🌐 Web Hosting Books 🐳 Docker Books 🐧 Linux Books 🐍 Python Books 💰 Affiliate Marketing 💵 Passive Income Books 🖥️ Server Books ☁️ Cloud Computing Books 🚀 DevOps Books 🤖 Xiaomi MiMo Platform
← Back to Home