LazyTools

🔒 Every tool runs in your browser — the files and values you enter are never uploaded to any server. How it works

explainer

How to Decode a Protobuf Message Without the .proto Schema

By the LazyTools team · Published 2026-08-03 · Updated 2026-08-23 · 8 min read

A protobuf message decoded field by field: tag byte splits into field number and wire type, then the value

You’ve captured a Protocol Buffers payload — from an API, a gRPC call, a WebAuthn response — but you don’t have the .proto file. Can you still read it? Yes. Protobuf’s wire format is self-describing enough to recover the whole structure — every field’s number, wire type and raw value — without a schema. What you can’t recover are the field names and the exact declared types, because those never travel on the wire. Here’s how the encoding works and how to decode a message with the Protobuf Decoder.

A protobuf message shown as raw hex bytes 08 96 01. The first byte is the tag; its low 3 bits give the wire type and the upper bits give the field number, so byte 08 decodes to field 1 with wire type 0. The remaining bytes 96 01 are the varint value 150. Below, the four wire types are listed: 0 varint, 1 I64, 2 length-delimited, 5 I32. A final panel shows that field numbers, wire types and raw values are recoverable without the proto schema, while field names and exact types are not.
How a protobuf tag byte splits into a field number and a wire type, letting you decode the structure with no schema.

The key idea: every field is tagged

A protobuf message is just a flat sequence of fields laid end to end, and each field begins with a tag — a varint that packs two pieces of information together:

tag = (field_number << 3) | wire_type

The low 3 bits are the wire type (how to read the value that follows); everything above is the field number. Because that tag travels with the data, a reader always knows where each field starts and how many bytes it spans — even with no schema. That is the whole reason schema-less decoding is possible, and it is also why protobuf can add new fields without breaking old readers: an unrecognised field number still has a readable wire type, so a parser can skip it cleanly.

The four wire types

Wire typeNameReads asUsed for
0varintbase-128 integerint32/64, uint, bool, enum, sint (zigzag)
1I648 fixed bytesfixed64, sfixed64, double
2LENlength varint + bytesstring, bytes, embedded message, packed repeated
5I324 fixed bytesfixed32, sfixed32, float

(Wire types 3 and 4 were “start group” and “end group” — a legacy framing that is deprecated and rarely seen.) A varint is a little-endian base-128 integer in which each byte carries 7 bits of value and uses its high bit as a “more bytes follow” flag. A LEN field is a varint byte-length followed by exactly that many payload bytes, which is why length-delimited data can be skipped without understanding its contents.

Reading a varint by hand

The varint is the one piece of the format worth decoding manually once, because everything else builds on it. Take the two value bytes 96 01:

  1. Write each byte in binary: 0x96 = 1001 0110, 0x01 = 0000 0001.
  2. Drop each byte’s high “continuation” bit. The first byte’s high bit is 1 (more follow); the second’s is 0 (this is the last byte). Remaining 7-bit groups: 001 0110 and 000 0001.
  3. Reverse the byte order (varints are little-endian, least-significant group first) and concatenate: 0000001 + 001011000000010010110.
  4. That binary is 150.

Do that once and the mechanic sticks: keep 7 bits per byte, stop at the first byte whose top bit is 0, and read the groups back to front.

A worked example

Take the canonical example from Google’s own encoding docs — a message with field 1 (an int32) set to 150:

08 96 01
  • 08 is the tag: 0x08 >> 3 = 1 (field 1), 0x08 & 7 = 0 (wire type varint).
  • 96 01 is the varint we just decoded by hand → 150.

Add a string field 2 = "testing" and a nested message in field 3, and the decoder shows:

1: 150                    (varint)
2: "testing"              (len → string)
3: { 1: 150 }             (len → nested message)

That last field is length-delimited bytes that happen to parse as another valid message — so the decoder expands it inline rather than dumping raw hex. The reason it can do that is the same self-describing tag: the nested bytes begin with their own 08 96 01, which is again a clean field-1 varint.

What a schema-less decode can’t give you

Two things are simply not present in the serialized bytes:

  • Field names. The wire carries field numbers, not names — names live only in the .proto. So you get 1:, not userId:. Recovering the meaning means lining the numbers up against the schema (or against your knowledge of the API) afterwards.
  • Exact types. A varint could be an int32, int64, bool, enum or a zigzag sint; a LEN field could be a UTF-8 string, an opaque bytes blob or a sub-message. The decoder shows the plausible readings — unsigned and zigzag interpretations for a varint, message-then-string for a LEN — but only the schema makes any one of them definite.

The table below summarises what survives the trip across the wire and what does not:

InformationRecoverable without .proto?Why
Field numberYesEncoded in every tag
Wire typeYesLow 3 bits of every tag
Raw value bytesYesFramed by wire type / LEN length
Repeated vs singlePartlyRepeats show as duplicate field numbers
Field nameNoLives only in the schema
int vs bool vs enumNoAll share wire type 0
string vs bytes vs messageHeuristicAll share wire type 2

These blind spots are a property of the format, not a limitation of any particular tool — protoc --decode_raw has exactly the same ones.

Signed integers and the zigzag trick

The one varint reading that trips people up is a signed value. Protobuf offers sint32/sint64 types that store negatives compactly using zigzag encoding, which maps small-magnitude numbers — positive or negative — to small unsigned varints: 0 → 0, -1 → 1, 1 → 2, -2 → 3, 2 → 4, and so on. The round-trip formula for decoding is value = (n >> 1) ^ -(n & 1), where n is the raw varint.

So if a schema-less decode shows a field as the plain varint 3 and the number looks nonsensical as a count or ID, try the zigzag reading: (3 >> 1) ^ -(3 & 1) = 1 ^ -1 = -2. Without the .proto you can’t know whether the author declared that field int32 (in which case it really is 3) or sint32 (in which case it’s -2) — the wire bytes are identical either way. A good decoder simply shows you both candidate interpretations and lets you pick using context. Note that a plain negative int32 is not zigzagged: it is stored as a full-width 10-byte varint, so a suspiciously long varint is itself a hint that you’re looking at a negative signed value.

protoc —decode_raw vs a browser decoder

Both approaches read the same self-describing bytes and produce the same field-number/wire-type/value breakdown — the difference is entirely in ergonomics and where the data goes:

protoc --decode_rawBrowser-based decoder
Install requiredYes — the protobuf compiler toolchainNone — runs in the page
Input formatRaw bytes on stdinPaste hex or base64
Where bytes goStay local (CLI)Stay local (client-side)
Nested message expansionYesYes
Signed/zigzag alternativesShows one readingOften shows both readings
Best forScripting, CI, piping capturesOne-off inspection, no setup

If you already have protoc installed, piping a captured body into protoc --decode_raw is quick. If you don’t — or you’re on a locked-down machine, or you just want to paste a base64 string and read it — a client-side web decoder gets you the same answer with nothing to install and nothing uploaded.

A practical workflow

When a payload lands in front of you with no schema, a repeatable order of operations helps:

  1. Get the bytes into hex or base64. Most capture tools (browser dev-tools, grpcurl, a proxy) can copy the raw body in one of those encodings.
  2. Decode the structure to see the field numbers, wire types and values.
  3. Look for anchors. A recognisable string (“testing”, an email, a URL) or a plausible timestamp often tells you which field is which faster than any schema.
  4. Map numbers to meaning. If you have the .proto, match field numbers against it; if you don’t, note the numbers and infer from the API’s behaviour.
  5. Treat ambiguous varints carefully. If a number looks wrong, try the zigzag reading — a small negative value stored as sint shows up as a large unsigned varint.

Decode a payload privately

API and gRPC payloads often carry personal data — tokens, user IDs, message contents — so you don’t want to paste one into a server-side decoder that uploads the bytes. The Protobuf Decoder parses the input — as hex or base64 — entirely in your browser, expanding nested messages and showing each field’s number, wire type and value, with nothing sent to any server. Decode first, then match the field numbers against your .proto (or the API you’re inspecting) to recover the full meaning.

Frequently asked questions

Can you decode a protobuf message without the .proto file?

Yes — partially. The Protocol Buffers wire format encodes each field's number and wire type directly in the bytes, so you can always recover the structure: how many fields there are, their field numbers, their wire types and their raw values. What you can't recover without the .proto schema is the field names and the exact declared types, because those are never sent on the wire. This is exactly what protoc --decode_raw does.

What is protoc --decode_raw?

A mode of the protobuf compiler that decodes an unknown serialized message without a schema, printing each field by number and wire type with its value. It's the standard way to inspect a protobuf payload when you don't have (or don't know) the .proto definition. A browser-based protobuf decoder does the same thing without installing protoc.

How does protobuf encode a field?

Each field starts with a varint 'tag' that packs two things: the field number shifted left by 3 bits, and a 3-bit wire type in the low bits — so tag = (field_number << 3) | wire_type. The wire type (0 varint, 1 sixty-four-bit, 2 length-delimited, 5 thirty-two-bit) tells the reader how to read the value that follows. That self-describing tag is why schema-less decoding is possible.

What are protobuf wire types?

There are four in current use: 0 (varint) for integers, booleans and enums; 1 (I64) for fixed 64-bit values like double or fixed64; 2 (LEN) for length-delimited data such as strings, bytes and embedded messages; and 5 (I32) for fixed 32-bit values like float or fixed32. Wire types 3 and 4 were groups, now deprecated.

Why can't a decoder tell a string from a nested message?

Both strings, raw bytes and embedded messages use the same wire type (2, length-delimited), and the format doesn't distinguish them. A decoder guesses: it tries to parse the bytes as a nested message, and if that fails cleanly it treats them as a UTF-8 string, otherwise as raw bytes. That's a heuristic — the same one decode_raw uses — not a certainty.

Is it safe to decode a protobuf payload online?

Only with a client-side tool. Protobuf payloads from APIs or gRPC can contain private data. The LazyTools Protobuf Decoder runs entirely in your browser and never uploads the bytes, so a sensitive payload stays on your machine — unlike server-based decoders.