Guide · Backend
JSON vs Protobuf vs Avro: Size, Speed and Schema Changes
24 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- JSON vs Protobuf comes down to text versus binary. JSON writes every field name as readable text in every message, while Protobuf sends small field numbers and binary values (raw bytes, not readable characters).
- Avro, an open-source Apache data format, goes one step further and sends only the values, in schema order. The reader must have the writer's schema, the written list of fields and their types.
- For one GPS ping in this guide, we measured 110 bytes as JSON, 41 as Protobuf and 36 as Avro. Speed depends on the library: in our Python test, Protobuf decoded fastest.
- Schema evolution means changing fields without breaking old readers. Protobuf relies on field numbers you never reuse, and Avro matches fields by name and fills gaps with default values.
- Use JSON for browsers, Protobuf for service calls and devices, and Avro for data files and Kafka (a shared message log) pipelines. Beginners can stop after the section on when to use each format. Engineers then get runnable checks, a Python benchmark and five tested traps.
What goes wrong when every message is sent as JSON?
Two things go wrong as traffic grows, and they sit at the heart of the JSON vs Protobuf choice. Every message repeats its field names, so you pay for the same labels millions of times, and nothing stops a renamed field from silently breaking readers.
Picture a common setup. About 1,200 fishing boats each carry a GPS tracker that sends a position ping every 30 seconds over a metered mobile link, billed by the byte. Each ping goes out as one UDP packet, a single send with no connection set up first. The setup is made up, but the problems are real.
A ping is one small message: the boat's id, latitude, longitude, the time, speed and battery level. The harbour office and families on shore watch the pings on a live map. A data team also stores every ping for safety reports.
The first tracker firmware (the program inside the device) sends each ping as JSON text. Three problems show up in the first monsoon season.
- Data bills. Each SIM plan allows 10 MB (10 million bytes) a month. At 2,880 pings a day, JSON payloads (the message bytes themselves) come to about 9.5 MB in a 30-day month. Each packet also carries 28 bytes of IPv4 and UDP headers, the addressing and control data the network adds, which pushes the total near 12 MB.
- Lost pings. Near the edge of coverage the signal drops for seconds at a time. A longer message is on air for longer, so it has more time to be cut off.
- A broken report. New firmware renamed the field battery to battery_pct. The battery report quietly showed blanks for a week before anyone noticed.
The first two problems are about size. The third is about schema evolution: changing the shape of data while old devices and old readers keep running. Boats get new firmware only in harbour, so old and new versions run side by side for months.
Which words do you need before comparing JSON, Protobuf and Avro?
You need four words: serialization, schema, binary encoding and field tag. Each gets a plain meaning and an example from the boats.
Serialization means turning an object in your program into bytes you can send or store. Deserialization turns the bytes back into an object. The tracker serializes a ping before sending it.
A schema is a written description of the data: which fields exist, their names and their types. For the ping, boat_id is text, lat is a decimal number and battery_pct is a whole number.
A binary encoding stores values as raw bytes instead of readable characters. The number 81 is two characters in text, but fits in one byte in binary.
A field tag is a small number that stands in for a field name inside the bytes. Protobuf can send the number 6 instead of the eleven letters of battery_pct.
Why does the format of a message change how much it costs to send?
The format decides how many bytes each message carries, and on a metered link you pay for every byte. It also decides how much the sender and receiver must agree on in advance.
Think of an old telegraph office that charges per word. The first way to send a message is to spell everything out: BOAT ID VSK 0412, LATITUDE 17.686815, and so on. Any clerk can read it, but you pay for every label.
The second way uses agreed short codes. Both offices keep the same numbered list, so you send 1 VSK0412, 2 17.686815. You pay far less, and a clerk who sees an unknown number can skip that item.
The third way sends only the values, in a fixed order, with no labels at all. That works only if the receiver holds the key that lists the order. So you either attach the key or send it once for a whole bundle.
Those three ways are JSON, Protobuf and Avro. Where the picture breaks: a clerk can see where an unknown item ends. A Protobuf reader cannot, so each tag carries a hint about the value's length, as you will see.
Now look at where a ping travels, and notice that two very different readers consume the same data.
Kafka is a distributed log: an ordered list of messages that many readers consume at their own pace. A data lake is a big store of raw files kept for later analysis. A schema registry, explained below, keeps every schema version.
Where one ping goes
Drag to exploreOne ping is read twice: by the live map within seconds, and by the report jobs months later. The radio hop cares about size; the files care about change.
Diagram connections:
- GPS tracker on a boat to Weak coastal mobile signal (Ping bytes)
- Weak coastal mobile signal to Ingest server
- Ingest server to Kafka topic: pings (Publish)
- Kafka topic: pings to Live boat map (Read live)
- Ingest server to Schema registry (Look up schema id)
- Kafka topic: pings to Data lake files (Batch to files)
- GPS tracker on a boat: Sends a ping every 30 seconds
- Weak coastal mobile signal: Metered SIM, drops for seconds
- Ingest server: Decodes pings and checks them
- Kafka topic: pings: Ordered log many readers consume
- Live boat map: Harbour office and families
- Schema registry: Stores every schema version
- Data lake files: Years of pings for reports
JSON vs Protobuf: what is the real difference?
JSON carries its field names as text inside every message, while Protobuf carries small numbers and relies on a shared schema file. Protobuf is smaller and stricter; JSON is readable and needs no setup.
Here is the first JSON ping, written compactly with no spaces.
{"boat_id":"VSK-0412","lat":17.686815,"lon":83.218482,"sent_at":1792040400,"speed_knots":6.5,"battery_pct":81}
That is 110 bytes. The quoted field names take 54 bytes, and braces, colons and commas take 13 more. So 61 percent of the message is labels and punctuation, not data.
JSON, short for JavaScript Object Notation, is defined by a short standard, RFC 8259. It covers only syntax: objects, arrays, strings, numbers, true, false and null. It has no schema built in, so nothing stops a sender from renaming a field.
Protobuf, short for Protocol Buffers, works differently. You first write a schema in a .proto file, where each field gets a name, a type and a field number. A tool called protoc then generates code that reads and writes these messages.
| Field name | Number | Type | Bytes in this ping |
|---|---|---|---|
| boat_id | 1 | string | 10 (tag, length, 8 letters) |
| lat | 2 | double | 9 (tag plus 8 bytes) |
| lon | 3 | double | 9 (tag plus 8 bytes) |
| sent_at | 4 | int64 | 6 (tag plus 5-byte varint) |
| speed_knots | 5 | float | 5 (tag plus 4 bytes) |
| battery_pct | 6 | int32 | 2 (tag plus 1-byte varint) |
The same ping in Protobuf is 41 bytes. The names never travel. Each value starts with a tag, which packs the field number together with a wire type, a small code that tells the reader how to find where the value ends.
Whole numbers use varints (variable-length integers): values from 0 to 127 take one byte and bigger ones take more. The official encoding guide shows the classic case: the value 150 in field 1 becomes three bytes, 08 96 01.
How does Avro make messages smaller without field tags?
Avro sends only the values, in the order the schema lists them, with no names and no tags. Strings carry a length first, whole numbers use a varint, and floats and doubles take 4 and 8 bytes. Apache Avro is an open-source data format whose schemas are written in JSON.
Pause and predict before reading on. Protobuf spent one tag byte on each of six fields. If Avro drops the tags, how big should the same ping be?
It is 36 bytes, not 35. Avro drops all six tag bytes, but battery 81 takes two bytes instead of one, because Avro stores every whole number in a signed form explained in the engineer section.
The Avro 1.12.0 specification is direct about the cost. Binary Avro data includes no type information or field names, so a reader cannot decode it without the writer's schema. In telegraph terms, the key travels separately.
So where does the schema go? Avro and its tools give you three common places.
- In a file header. An Avro object container file stores the writer's schema once at the top, then records in blocks. Our file of one boat's day (2,880 pings) averaged 36.2 bytes a ping.
- In a fingerprint. Avro's single-object encoding puts a two-byte marker and an eight-byte fingerprint (a short number computed from the schema) before each record, so one ping becomes 46 bytes.
- In a schema registry. A registry is a small service that stores every schema version under an id. In the documented wire format of Confluent Schema Registry, a widely used registry, each message its serializers write starts by default with one zero byte and a four-byte id. A newer opt-in mode moves the id into a message header instead, as of 19 October 2026.
With that five-byte prefix, one Avro ping is 41 bytes. Protobuf sent through the same registry gets the prefix too, plus a short list of message indexes. Avro wins when many records share one schema, as in a day's file.
What is schema evolution, and how does each format handle it?
Schema evolution is changing the shape of your data, such as adding, removing or renaming fields, while old writers and old readers keep working. Each format gives you different rules for doing it safely.
Two more words help. Backward compatible means new readers can read old data. Forward compatible means old readers can read new data. The boats need both, because trackers at sea run old firmware for months.
JSON gives freedom with no guard
JSON lets you send anything, and that is the problem. Adding a field is usually fine if readers ignore keys they do not know. Renaming a field breaks readers silently, which is exactly what happened to the battery report.
You can add checks with JSON Schema, a separate specification for describing JSON. Nothing forces a sender to use it, so the discipline lives in your team and your tests.
Protobuf skips what it does not know
A Protobuf reader reads one field at a time and skips any field number it does not know. The wire type in each tag says how long the value is, so the reader can jump over it safely.
Suppose new firmware adds field 7, an sos flag (a distress button) the crew can press. A server still running the old schema meets field 7 and skips it. Follow the arrows to see how.
How a Protobuf reader handles each field
Drag to exploreUnknown fields are skipped, not treated as errors. In our test the old reader also kept field 7 and wrote it back out unchanged.
Diagram connections:
- Ping bytes arrive to Read the next tag
- Read the next tag to Field number in my schema?
- Field number in my schema? to Decode the value (Yes)
- Field number in my schema? to Skip the value (No, like field 7)
- Decode the value to Ping object ready
- Skip the value to Ping object ready
- Ping bytes arrive: 43 bytes from new firmware
- Read the next tag: Field number plus wire type
- Field number in my schema?: Old server knows 1 to 6 only
- Decode the value: The schema says what it means
- Skip the value: Kept as an unknown field
- Ping object ready: Missing fields get default values
The other direction works too. A new server reading a ping from old firmware finds no field 7 and returns the default, which is false for a true-or-false field. The proto3 language guide states both rules.
Protobuf's other rules all protect the field numbers. The guide says changing a field's number is the same as deleting the field and adding a new one.
- Add a field with a brand new number, and decide what its default value means for old data.
- Rename a field freely in the binary format, since names are not sent. Protobuf's JSON form does use names, so take care there.
- Delete a field by marking its number and name as reserved, so nobody reuses them. Old pings may still contain that number.
- Change a type only between pairs the guide lists as compatible, such as int32 and int64, and roll out with care.
Avro matches by name and fills gaps with defaults
Avro reads with two schemas at once. The writer schema is the one used to write the bytes. The reader schema is the one your code expects now. Avro's resolution rules translate between them.
The specification matches fields by name, not position. A field the writer had but the reader dropped is ignored. A field the reader wants but the writer never had takes the reader's default; with no default, the read fails.
Follow the arrows below. A report job reads an old ping, written before the sos field existed, using the new schema.
How Avro reads an old ping with a new schema
Drag to exploreThe writer's schema comes from the registry and the reader's schema from the job's code. A missing default turns an old ping into an error.
Diagram connections:
- Old ping written with v1 to Schema registry (Tagged with schema id)
- Schema registry to Report job (Writer schema v1)
- Report job to Match fields by name
- Match fields by name to Does sos have a default?
- Does sos have a default? to Use the reader's default (Yes)
- Does sos have a default? to Fail the read (No)
- Old ping written with v1: Schema v1: no sos field
- Schema registry: Returns v1 by its schema id
- Report job: Expects schema v2 with sos
- Match fields by name: boat_id, lat, lon and the rest
- Does sos have a default?: sos is missing in v1 pings
- Use the reader's default: sos becomes false
- Fail the read: SchemaResolutionError in fastavro
Renames work differently in Avro. Because fields match by name, a rename needs an alias: the reader's schema lists the old name, so old data still maps to the new field. Without the alias, our test failed the read.
When should you use JSON, Protobuf or Avro?
Use JSON when people or browsers read the data, and Protobuf when services or devices exchange messages. Use Avro when you store or stream large volumes that many teams read for years. Many systems use all three.
That is where the boat system ends up. The trackers move to Protobuf, and payloads drop to about 3.5 MB per boat per month. With the 28 header bytes per packet, the total is about 6 MB, under the 10 MB plan.
The ingest server, which receives every ping, turns each Protobuf ping into Avro for Kafka and the data lake. Every schema version lives in a registry set to reject changes that break compatibility. The live map still gets JSON, because browsers read it natively.
Choosing a format for one hop
Drag to exploreAsk the question for each hop separately. One system can use all three formats, and the boat system does.
Diagram connections:
- Pick a format for this hop to Browser or public API?
- Browser or public API? to Use JSON (Yes)
- Browser or public API? to Stored or streamed in bulk? (No)
- Stored or streamed in bulk? to Use Avro with a registry (Yes)
- Stored or streamed in bulk? to Use Protobuf (No, live messages)
- Pick a format for this hop: One link between two parts
- Browser or public API?: Outside developers, web pages
- Use JSON: Readable, works everywhere
- Stored or streamed in bulk?: Files, Kafka, years of history
- Use Avro with a registry: Schema once, values only
- Use Protobuf: Service calls and small devices
| JSON | Protobuf | Avro | |
|---|---|---|---|
| Bytes for one ping | 110 | 41 | 36 bare, 41 with a registry id, 46 with a fingerprint |
| Payload per boat per month | About 9.5 MB | About 3.5 MB | About 3.1 MB bare |
| With IPv4 and UDP headers | About 11.9 MB | About 6.0 MB | About 5.5 MB bare |
| Field names in the bytes | Yes, every message | No, field numbers | No, only values |
| Schema needed to read | No | For meanings, not to skip fields | Yes, the writer's schema |
| Human can read the bytes | Yes | No, needs a tool | No, needs a tool |
| Renaming a field | Breaks readers | Safe in binary, names not sent | Needs an alias in the reader |
| Typical home | Browsers and public APIs | Service calls and devices | Data files and Kafka pipelines |
Plain JSON is still the right call when the message count is small, outside developers consume the data, or the shape changes weekly and nobody owns schema files yet.
Beginners can stop here. The rest of the guide is for engineers: commands to check every number above, a decode benchmark, and five traps we reproduced on pinned versions.
How can you check the byte sizes and schema rules yourself?
Run the three files below with the pinned versions, and you should see the same byte counts. We ran them on Python 3.13.16 with protobuf 7.36.2, grpcio-tools 1.84.0 (which bundles protoc 35.1) and fastavro 1.13.1.
First, the Protobuf schema. Save it as ping.proto.
syntax = "proto3";
message Ping {
string boat_id = 1;
double lat = 2;
double lon = 3;
int64 sent_at = 4;
float speed_knots = 5;
int32 battery_pct = 6;
}Next, sizes.py encodes the same ping three ways and prints the sizes. It also prints the Protobuf bytes in hex (base 16, two digits per byte), so you can match them to the table above.
import io
import json
import fastavro
from ping_pb2 import Ping # made by protoc from ping.proto
ping = {"boat_id": "VSK-0412", "lat": 17.686815, "lon": 83.218482,
"sent_at": 1792040400, "speed_knots": 6.5, "battery_pct": 81}
as_json = json.dumps(ping, separators=(",", ":")).encode("utf-8")
as_proto = Ping(**ping).SerializeToString()
schema = fastavro.parse_schema({"type": "record", "name": "Ping", "fields": [
{"name": "boat_id", "type": "string"},
{"name": "lat", "type": "double"},
{"name": "lon", "type": "double"},
{"name": "sent_at", "type": "long"},
{"name": "speed_knots", "type": "float"},
{"name": "battery_pct", "type": "int"}]})
buf = io.BytesIO()
fastavro.schemaless_writer(buf, schema, ping)
as_avro = buf.getvalue()
print("JSON", len(as_json), "bytes")
print("Protobuf", len(as_proto), "bytes")
print("Avro", len(as_avro), "bytes")
print(as_proto.hex(" "))Then evolve.py writes a ping with the old two-field schema and reads it with the new schema, which adds sos with a default.
import io
import fastavro
v1 = fastavro.parse_schema({"type": "record", "name": "Ping", "fields": [
{"name": "boat_id", "type": "string"},
{"name": "battery_pct", "type": "int"}]})
v2 = fastavro.parse_schema({"type": "record", "name": "Ping", "fields": [
{"name": "boat_id", "type": "string"},
{"name": "battery_pct", "type": "int"},
{"name": "sos", "type": "boolean", "default": False}]})
buf = io.BytesIO()
fastavro.schemaless_writer(buf, v1, {"boat_id": "VSK-0412", "battery_pct": 81})
buf.seek(0)
print(fastavro.schemaless_reader(buf, v1, v2))Install the pinned versions, generate the Protobuf code and run both scripts. The comments show the output we got.
python3 -m venv venv && . venv/bin/activate
pip install protobuf==7.36.2 grpcio-tools==1.84.0 fastavro==1.13.1
python -m grpc_tools.protoc -I. --python_out=. ping.proto
python sizes.py
# JSON 110 bytes
# Protobuf 41 bytes
# Avro 36 bytes
# 0a 08 56 53 4b 2d 30 34 31 32 11 f9 66 9b ... 30 51
python evolve.py
# {'boat_id': 'VSK-0412', 'battery_pct': 81, 'sos': False}What just happened? The Protobuf bytes start with 0a 08: tag for field 1, then a length of 8. The old Avro ping gained sos as false because v2 supplies a default.
Now break it on purpose. Delete the default from v2 and run evolve.py again: fastavro 1.13.1 raises SchemaResolutionError, saying there is no default value for field sos.
Which is faster in practice: JSON, Protobuf or Avro?
In our Python test, the library mattered more than the format. Protobuf decoded one ping about three times faster than json.loads, even counting reading every field. The official avro package was several times slower than JSON.
The usual reasoning is sound but incomplete. A JSON reader scans every character and turns text such as 17.686815 into a number, while a binary reader gets values whose size is fixed or marked in the bytes. How much that matters depends on where the code runs.
Python's protobuf package uses a C core called upb, and json.loads also has a C core. fastavro ships compiled extension modules. The official avro package 1.12.2 is plain Python, with no compiled parts.
| Library and call | Decode | Encode |
|---|---|---|
| json.loads and json.dumps | 10.3 to 11.5 | 12.3 to 22.2 |
| protobuf 7.36.2, parse only | 1.3 to 1.5 | 2.6 to 4.6 |
| protobuf 7.36.2, parse and read all 6 fields | 3.3 to 4.0 | Not measured |
| fastavro 1.13.1 | 9.3 to 14.9 | 10.3 to 14.4 |
| avro 1.12.2 (official, pure Python) | 49 to 76 | 57 to 137 |
A microsecond is a millionth of a second. Read the ratios, not the raw numbers, because your machine will differ. Note the second protobuf row: reading the six fields after parsing more than doubled the time, so a parse-only benchmark flatters Protobuf.
So binary does not mean fast in every language. In Java, Go or C++ the picture can change again. Benchmark your real payloads with your real library before using speed as the reason to switch.
Which encoding details catch out experienced engineers?
Five details surprise people who already know the basics. We reproduced each one on protobuf 7.36.2, fastavro 1.13.1 and Node.js 22.22.0, as of 19 October 2026.
Negative numbers can cost ten bytes in Protobuf
A negative int32 or int64 value takes ten bytes, because Protobuf stores it in two's complement, the usual computer form for negatives, which sets the highest bits. In our test, int32 -1 made an 11-byte message, and sint32 -1 made a 2-byte one.
The sint32 and sint64 types use ZigZag encoding, which maps small negative numbers to small positive ones. Avro uses ZigZag for every int and long, so -1 is one byte, and 81 becomes 162, which needs two.
A zero may not be sent at all
In a proto3 file, a plain number, text or true-or-false field without the optional label, set to its default, such as battery_pct of 0, is left out of the bytes. A reader cannot tell a flat battery from a missing reading.
Mark the field optional to track presence; then 0 is sent and the HasField check reports it. Protobuf editions, the newer syntax that replaces the proto2 and proto3 labels, track presence by default. Our edition 2023 test sent the zero; the latest edition is 2024, as of 19 October 2026.
Protobuf's JSON form changes names and drops unknown fields
When you print a Protobuf message as JSON, field names become lowerCamelCase (no underscores, later words capitalised), so battery_pct turns into batteryPct. 64-bit integers become strings, so sent_at appears as "1792040400" in quotes.
Unknown fields are lost too. In our test, an old server converted a new ping to JSON and back, and the sos flag set to true came out false.
Big ids lose digits in JavaScript
RFC 8259 says whole numbers between -(2 to the power 53) + 1 and 2 to the power 53 - 1 are interoperable. Outside that range, readers may disagree.
In Node.js 22.22.0, JSON.parse turned the id 9007199254740993 into 9007199254740992. Python's json module kept it exact. Send large ids as strings in JSON.
Reusing a field number gives wrong data, not an error
The proto3 guide warns that reusing a field number can cause parse errors, leaked personal data or data corruption. Our test showed the quiet version of that.
We read an old ping, where field 6 meant battery, with a schema where field 6 meant heading_deg. The reader reported a heading of 81 degrees without complaint. When we redefined field 6 as text, Python's protobuf 7.36.2 showed an empty field with no error and kept the 81 only as an unknown field.
That last number is flattering, since our 2,880 pings were identical copies. Real pings differ, so compress them in batches and measure on real data.
How do you explain JSON vs Protobuf vs Avro in a system design interview?
Explain them as three points on one scale: how much each message describes itself, versus how much both sides must agree in advance. Then tie your choice to size, speed and change for each hop.
Start with the scale
JSON carries names, Protobuf carries numbers, and Avro carries only values. Less in the message means more agreement needed beforehand.
Put numbers on size
Do one estimate out loud. 1,200 devices sending 110-byte pings every 30 seconds is 4,400 payload bytes a second; at 41 bytes it is 1,640. Then add the headers each packet carries.
Talk about speed honestly
Say decode speed depends on the library and language, and that you would benchmark real payloads before switching for speed alone.
Explain schema evolution
Protobuf never reuses field numbers. Avro matches names and needs defaults. JSON has no rules unless your team adds them.
Show the mixed design
Protobuf from devices, Avro in Kafka with a registry, JSON to the browser. Explain why each hop got its format.
Name the failure you prevent
Mention one real risk, such as a renamed field breaking a report or a reused field number, and the rule that stops it.
Questions people ask
- Is Protobuf always smaller than JSON?
- No, though it usually is for the same data, because it sends field numbers instead of names. One exception is negative int32 or int64 values: each takes ten bytes in Protobuf, while -1 is two characters in JSON. Use sint32 or sint64 for fields that are often negative. For mostly-text messages the gap also shrinks.
- Is Avro better than Protobuf?
- Neither is better everywhere. Avro is often smaller in files and fits data pipelines, because one schema covers many records and names drive evolution. Protobuf suits single messages and service calls, because any reader with the .proto file can decode a message on its own.
- Can I convert Protobuf to JSON?
- Yes. Protobuf has an official JSON form, and most libraries can print a message as JSON. Be careful in pipelines: field names become lowerCamelCase, 64-bit integers become strings, and the proto3 guide lists serializing to JSON as one way unknown fields are lost.
- Do I need a schema registry for Avro?
- Not always, but every reader needs some way to get the writer's schema. Avro container files carry it in their header, so files need no registry. For Kafka messages a registry is the common answer, because each message then carries a short schema id instead of the full schema.
- What happens if I reuse an old Protobuf field number?
- Old data can be read as the wrong field. The proto3 guide lists parse errors, leaked personal data and data corruption as possible results. In our test, an old battery reading of 81 was read as a heading of 81 degrees, with no error. Reserve deleted numbers so nobody reuses them.
- Is JSON slow?
- Not always. In our Python test, json.loads decoded a ping in about 11 microseconds, close to fastavro and far faster than the pure-Python avro package. Protobuf was faster still. Switch formats when you can measure the cost in bandwidth, CPU time or money on your own payloads.
Sources and further reading
- Protocol Buffers documentation: Encoding (varints, tags, wire types, ZigZag for sint types)
- Protocol Buffers documentation: Language Guide (proto 3), field numbers, updating a message type, unknown fields
- Protocol Buffers documentation: Application note on field presence (proto3 and editions)
- Protocol Buffers documentation: ProtoJSON format (lowerCamelCase names, 64-bit integers as strings)
- Protocol Buffers documentation: Editions overview (editions replace proto2 and proto3; latest released edition)
- Apache Avro 1.12.0 Specification: binary encoding, container files, single-object encoding, schema resolution, aliases
- Confluent documentation: Schema Registry serializers, wire format section
- RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format, section 6 on numbers
- Schema evolution in Avro, Protocol Buffers and Thrift (Martin Kleppmann, 2012)