JSON to Protobuf Definition
Turn sample JSON into a proto3 .proto message with stable field numbers — entirely on this device.
Field numbers are the wire contract: they follow first-seen key order, 1–15 cost one tag byte, and a number that has shipped must never be reused — reserve it instead.
The proto3 definition appears here as you type.
Press ? for shortcuts
Do it offline:$ protoc --proto_path=. --descriptor_set_out=/dev/null schema.proto
How to use it
- Paste one response, or drop a .json file on the left pane — the definition rebuilds as you type.
- Open a sample tab per payload, up to ten: a key that is missing from any tab comes back marked optional.
- Set the package and root message name, then choose nesting, integer width, and whether RFC 3339 strings become google.protobuf.Timestamp.
- Copy the file, download it as .proto, or share a permalink that reopens the same tabs and the same options.
Paste sample JSON and read back a proto3 .proto: one message per object shape, field numbers in the order the keys first appeared, explicit optional for anything a sample left out, and json_name wherever snake_case would change the JSON on the wire. Take the numbering as a first draft you now own — a field number is the wire contract, and no compiler runs here. Nothing is uploaded.
Examples
{"id":42,"name":"Ada","active":true,"score":9.5,"address":{"city":"Paris","zip":"75001"},"tags":["a","b"]}syntax = "proto3";
message Root {
message Address {
string city = 1;
string zip = 2;
// A field number is the wire contract — retire it, never reuse it:
// reserved 4, 7;
// reserved "old_name";
}
int64 id = 1;
string name = 2;
bool active = 3;
double score = 4;
Address address = 5;
repeated string tags = 6;
// A field number is the wire contract — retire it, never reuse it:
// reserved 4, 7;
// reserved "old_name";
}
Defaults, one payload. The nested object becomes its own message inside its parent, whole numbers land on int64 and 9.5 on double, and each message closes with the reserved reminder — a comment, never live syntax, since a generated file must not retire numbers you never shipped.
{"user_id":42,"avatar-url":"https://example.com/a.png","created_at":"2026-08-11T10:00:00Z","tags":["new"]}
{"user_id":7,"created_at":"2026-08-11T11:30:00Z","tags":[]}syntax = "proto3";
package demo.v1;
import "google/protobuf/timestamp.proto";
message Root {
int64 user_id = 1 [json_name = "user_id"];
optional string avatar_url = 2 [json_name = "avatar-url"];
google.protobuf.Timestamp created_at = 3 [json_name = "created_at"];
repeated string tags = 4;
}
Two payloads, timestamps on, reminders off. The second tab has no avatar, so that field alone takes optional. A dash cannot appear in an identifier, so json_name carries the original spelling back; user_id and created_at need it too, because protobuf would otherwise print them as userId and createdAt.
{"legacy_field":null,"tags":[],"mixed":[1,"a",{}]}syntax = "proto3";
import "google/protobuf/struct.proto";
message Root {
google.protobuf.Value legacy_field = 1 [json_name = "legacy_field"];
repeated google.protobuf.Value tags = 2;
repeated google.protobuf.Value mixed = 3;
}
The edge case: three keys that carry no type. Always null, always empty, and three kinds of element at one position are all unknowable here, so each takes the well-known Value and the file imports struct.proto. Each raises its own note — nothing is dropped in silence.
Every example on this page is executed against the same engine that powers the tool, on every build.
Gotchas worth knowing
Numbers 1 to 15 are the cheap ones
Those tags encode in a single byte, 16 to 2047 in two. Numbering follows the order your keys first appeared and stops there: moving hot fields to the front changes your wire format, so it stays your call.
19,000 to 19,999 are not yours
That block belongs to the implementation, so allocation jumps over it and says so. The ceiling is 536,870,911; past it the run is refused rather than emitting a file protoc would reject.
json_name keeps the wire JSON identical
A protobuf printer derives the JSON key from the field name by dropping each underscore and capitalising what follows, turning user_id into userId. Where that differs from the key you pasted, the field carries json_name with your original — byte for byte.
A dictionary looks exactly like a record
Inference sees {"usd":1,"eur":2} as an object with two fields, because nothing in one payload separates data-shaped keys from declared ones. So a map<string, T> is a choice you make after reading the output, not something a sample can prove.
optional is presence, not validation
It buys one thing: telling an unset field apart from a zero or an empty string. Lists and maps cannot take it, and those fields say why they were left unmarked.
Timestamps are opt-in and strict
Only a string that is a full RFC 3339 date-time in every sample is promoted, offset included. A near miss such as 2026-08-11 10:00 stays a plain string.
Common errors
Sample 2: Unexpected '}' at line 1, column 9 — probable cause: trailing comma after the last property.- Nothing is generated until every open tab is strict JSON. The prefix names the failing tab, and Jump to error puts the cursor on the exact character.
Input is larger than the 10 MB limit for this tool.- The combined size of all tabs is measured before any work starts, and a run past the ceiling is refused whole rather than trimmed. Three representative payloads teach the shape as well as thousands do.
A .proto file describes messages, so the samples must hold a JSON object (or an array of JSON objects) at the top level.- A bare string, number or boolean has no fields to number. Wrap it in the object your endpoint returns; an array of objects is read as repeated copies of one element message.
"1bad-pkg" is not a proto package name. Use dot-separated names built from letters, digits and underscore, each starting with a letter or underscore — for example demo.v1.- The package decides where the generated symbols live, so an unusable name stops the run instead of quietly emitting the file without one. Dashes and leading digits are the usual causes.
Root.post_id carried an integer beyond ±(2^53 − 1), which JavaScript JSON parsers round. Protobuf's JSON mapping sends 64-bit integers as strings for that reason — keep them quoted end to end.- A warning, not a refusal: the field stays int64, which holds the value fine. What breaks is the trip through a JSON parser, which is why the JSON mapping quotes 64-bit integers.
FAQ
Can I hand this straight to protoc?
As a starting point. It is not compiler-verified in this environment — no compiler runs here — so the file is checked against the language guide and a frozen vector pack instead. Run the command below to get that confirmation from protoc itself.
Why did my field come back as google.protobuf.Value?
Because the payload gave it no single type: null throughout, an empty list throughout, or different kinds of value in different places. Paste a payload where it carries real data and a concrete type appears; or switch the fallback to string, or drop those fields.
What happens if I renumber a field later?
Every already-encoded message becomes unreadable, because the number is all the bytes carry. Delete the field, reserve its number and its old name so nobody takes either by accident, and give the replacement a fresh number.
Keyboard shortcuts
- ⌘/Ctrl + Enter
- Generate again, which is how a large paste starts.
- ⌘/Ctrl + S
- Save the definition as a .proto file, not the page.
- ⌘/Ctrl + C
- With nothing selected, copies the whole definition.
- ⌘/Ctrl + Shift + C
- Take the definition even while a selection is live.
- t
- Flip the theme, unless a text field has focus.
- ? or ⌘/Ctrl + /
- Bring up the list of shortcuts.
- Esc
- Dismiss the topmost overlay.