JSON to Protobuf: Generate a .proto Schema From Any Payload
Turn a JSON API response into a proto3 message: field numbers, scalar type mapping, repeated arrays, nested messages, and why protobuf is smaller and faster.
JSON to Protobuf: Generate a .proto Schema From Any Payload
I spend a fair amount of time moving services off REST and onto gRPC, and the slowest part is never the wiring. It's writing the .proto by hand to match a JSON shape that already exists. You stare at a 40-key response, guess at types, count field numbers, forget that userId should be user_id, and then protoc yells at you about a duplicate field number on line 31. The JSON to Protobuf converter does that mechanical part for you: paste a payload, get a proto3 message with types inferred, fields numbered, and nested objects named.
This post walks through what the conversion actually decides — field numbers, type mapping, arrays, nested messages — and why protobuf is worth the extra schema step in the first place.
Why protobuf instead of raw JSON
JSON is great for a curl and a glance. It is a poor wire format for high-volume service-to-service traffic, for three concrete reasons.
It is bigger. JSON ships every field name as a string on every message. A field called transaction_timestamp costs 21 bytes per record before you encode a single value. Protobuf sends a varint tag instead — one or two bytes that stand in for both the field number and the type. On a list of a few thousand records, that difference is the difference between one network packet and several.
It is slower to parse. A JSON parser scans characters, tracks string boundaries, and builds a generic tree of maps and arrays. Protobuf reads length-prefixed binary into a generated struct with no string-matching on keys. For a hot path that deserializes millions of messages, that gap shows up directly in CPU graphs.
It has no contract. JSON tells you nothing about whether a field is a number, whether it can be null, or whether it will still exist next release. A .proto is a typed, versioned contract that every language generates matching code from — Go, Java, Rust, Python, and the rest all read the same file.
Field numbers are the actual contract
The part of protobuf people misread first is field numbers. Each field gets a unique integer, and the converter assigns them in key order, restarting at 1 inside every message:
message User {
int64 id = 1;
string name = 2;
string email = 3;
}
1, 2, 3 — one per field, and id will never share a number with name. This matters because protobuf identifies fields on the wire by number, not name. That single fact drives the whole compatibility model:
- Renaming
emailtoemail_addressis free. The number3is unchanged, so old and new binaries still agree. - Reusing or reordering a number is a silent data-corruption bug. If you delete field
3and later add a new field as3, an old client writes a string where a new client reads something else.
So treat the generated numbers as a starting point. Before a schema goes to production, review the order and add reserved ranges for fields you know are coming, leaving gaps the converter won't have guessed at.
How JSON types map to proto scalars
JSON has exactly one number type and no width or sign hint, so the converter picks safe supersets rather than the tightest possible type:
- A whole-number sample infers
int64. JavaScript numbers are 64-bit floats, and JSON integers routinely exceedint32— millisecond Unix timestamps and snowflake IDs both blow past it.int64holds them. - A fractional sample infers
double, notfloat, to avoid silent precision loss. - A field that is whole in one sample and fractional in another widens to
double. true/falsebecomesbool, strings becomestring, and a value that is only evernulldegrades togoogle.protobuf.Anywith the matchingimportadded for you.
If you know a field is a small or unsigned ID, narrow it by hand to int32, uint32, or uint64 after pasting. The default is the type that round-trips every in-range JSON number; a stray -1 is exactly why the converter won't assume unsigned for you.
A worked example: nested objects and repeated arrays
Here is a payload close to what a real user endpoint returns:
{
"id": 80493118723,
"name": "Ada Lovelace",
"active": true,
"roles": ["admin", "billing"],
"address": {
"city": "London",
"zip": "EC1A"
}
}
Paste that, name the root GetUserResponse, and you get:
syntax = "proto3";
message GetUserResponse {
int64 id = 1;
string name = 2;
bool active = 3;
repeated string roles = 4;
GetUserResponseAddress address = 5;
}
message GetUserResponseAddress {
string city = 1;
string zip = 2;
}
Three things happened. The roles array of strings became repeated string — one field, marked as a list. The address object lifted out into its own named message, GetUserResponseAddress (PascalCase ${Parent}${Key}), referenced from the parent as field 5. And the nested message restarted its own numbering at 1. Note that id came through as int64: 80493118723 would overflow int32, which is the failure the default quietly prevents.
Arrays of objects fold the same way but smarter. Given [{"a":1},{"a":1,"b":2}], the converter produces a single message holding both a and b — the union of keys across elements — instead of two near-duplicate types. That's what makes it usable on real webhook logs where one payload carries a field another lacks.
Putting it into a gRPC or event-bus workflow
The output drops straight into the places a .proto lives. For a gRPC migration, copy it into user.proto, wrap the message in a service definition, and run protoc to generate server stubs and client code. Because the field numbers are already assigned, the file compiles on the first try and you only tune numbers, not invent them.
For an event bus, set the package to something like events.v1, paste a sample event, and register the resulting message in your schema registry. Consumers in different languages generate matching types from that one file — no more hand-syncing struct definitions across three repos.
If your input is messy — comments, trailing commas, JSON5 — clean it first, since the converter uses strict JSON.parse. Run it through the JSON formatter to normalize and validate the payload, then paste the result. Everything runs in your browser, so an internal production response never touches a server.
A typed contract is the cheapest documentation you will ever write. Generate the first draft from the JSON you already have, fix the field numbers once, and let every downstream service read a schema instead of guessing from a blob.
Made by Toolora · Updated 2026-06-13