Migrating legacy JSON APIs to gRPC frequently stumbles over a common anti-pattern: unstructured, dynamic JSON fields (such as metadata or extra_properties) mapped directly into Protobuf using google.protobuf.Value or google.protobuf.Struct.

These belong to Protobuf’s Well-Known Types (WKTs), a library of standardized, common message schemas defined by Google (such as Timestamp, Duration, and Any) that ship out-of-the-box with the Protobuf compiler to provide consistent representation for reusable data structures across different languages.

But what actually are these specific dynamic types under the hood, and how are they used?

The Value Message

At the core of dynamic Protobuf is google.protobuf.Value. If we look at its official definition, it is defined as a oneof containing the possible JSON-compatible data types:

message Value {
  oneof kind {
    NullValue null_value = 1;
    double number_value = 2;
    string string_value = 3;
    bool bool_value = 4;
    Struct struct_value = 5;
    ListValue list_value = 6;
  }
}

google.protobuf.Value represents a dynamically typed wrapper that can hold any single JSON-compatible value: null, a number (encoded as a double-precision float), a string, a boolean, a nested Struct (representing a JSON object), or a list of values (ListValue, representing a JSON array).

How Value is Used

Value is used to represent a single dynamic field whose specific type is not known at compile-time. Instead of declaring a rigid type like string or int32, defining a field as Value allows it to wrap any scalar, nested object, or list at runtime. In languages like Go, this compiles to a struct where the type of the value is inspected or retrieved via type assertions or helper getters on the underlying oneof interface.


The Struct Message

To represent a dynamic object structure (a collection of key-value pairs), Protobuf provides google.protobuf.Struct. Under the hood, it maps string keys to Value messages:

message Struct {
  map<string, Value> fields = 1;
}

In practice, Struct behaves like a JSON object. In Go, this translates directly to a key-value map (map[string]any) or a raw JavaScript object where the keys are strings and the values are dynamically typed.

How Struct is Used

Struct is used to carry arbitrary structured dictionaries, and can be utilized in two primary ways:

  1. Directly: As a top-level field in your message to represent unstructured configuration or metadata (e.g., google.protobuf.Struct metadata = 1;).
  2. Via Value: Since Struct is one of the options inside Value’s oneof kind (Struct struct_value = 5;), a Struct contains a map of keys to Values, which can themselves be Structs. This recursive relationship allows Struct and Value to together represent complex, deeply nested JSON objects containing nested lists and primitive values.

Together, they allow Protobuf messages to carry arbitrary, unstructured JSON-like payloads without declaring a strict schema beforehand. This is a common architectural pattern, and it successfully solves a real developer pain point: handling highly dynamic data. Since Protobuf is famous for being fast and compact, one would intuitively think that wrapping dynamic payloads in these well-known types would still be more efficient than standard JSON.

I decided to test that assumption, and the results were the complete opposite of what I expected. Most of Protobuf’s performance advantage comes from ahead-of-time schema knowledge. Statically compiled schemas are the primary optimization lever that allows Protocol Buffers to achieve speed and compactness. The moment you remove schema information and adopt unstructured types like google.protobuf.Value or google.protobuf.Struct, you give up many of the optimizations that make Protobuf fast and compact.

The Structural Cost of Dynamic Data

Before looking at the benchmark numbers, it helps to understand why google.protobuf.Value is structurally expensive on the wire compared to compact JSON. When representing arbitrary object structures, google.protobuf.Struct is defined under the hood as map<string, Value> fields = 1;.

In the Protobuf wire format, maps are not native primitives. Instead, they are represented as a repeated list of auto-generated key-value message entries. The map field is equivalent to:

message MapEntry {
  string key = 1;
  google.protobuf.Value value = 2;
}

// Inside google.protobuf.Struct:
repeated MapEntry fields = 1;

This explains why every key-value entry is serialized as a nested sub-message (MapEntry) containing two inner fields (key and value), each with its own tag and length overhead.

For tiny dynamic payloads, the structural metadata overhead of Struct can exceed the payload itself. While in practice payloads are enclosed in full objects and messages, comparing the raw serialization layout of a single dynamic key-value entry like "age": 30 illustrates this overhead:

Wire Format Layout Comparison

1. Compact JSON (10 bytes)

For this conceptual representation of JSON, containing a single key/value pair for age wrapped in enclosing object braces, we have:

{"age":30}

This payload is exactly 10 bytes in size:

  • 2 bytes for the enclosing object braces {}
  • 5 bytes for the key "age" (including quotes)
  • 1 byte for the colon separator :
  • 2 bytes for the numeric characters "30"

2. Dynamic Protobuf (Protoscope Representation)

If we describe the dynamic Protobuf binary payload using Protoscope (the language for representing raw Protobuf wire formats) to visualize the structure and tags:

1: {           # Struct.fields map entry header        -> 2 Bytes
  1: "age"     # MapEntry.key (tag/len + "age" string) -> 5 Bytes
  2: {         # MapEntry.value Value wrapper header   -> 2 Bytes
    2: 30.0    # Value.number_value double field       -> 9 Bytes
  }
}
  • Each set of braces {} represents a length-delimited sub-message, which compiles to a field tag followed by a length prefix byte on the wire.
  • The prefix tags 1: and 2: represent the field numbers.
  • 30.0 compiles to field tag 2 (wire type 1, 64-bit) followed by its fixed 8-byte float value.

Summing these up (2B + 5B + 2B + 9B) results in a total payload size of exactly 18 bytes for this specific encoding.

So with this context, we can now say why google.protobuf.Value is inefficient:

  1. Double Nesting and Header Overhead: In compact JSON, the valid payload {"age":30} consumes 10 bytes total. In dynamic Protobuf, the nested schema-less structure requires 18 bytes. The nested header metadata alone (the field tags and length prefixes for each layer) consumes 7 bytes, which is nearly as large as the entire JSON payload before any key or value content is even written.

  2. No Field Name Compression: One of Protobuf’s largest size advantages usually comes from discarding human-readable field names (like "age") and replacing them with compact, 1-byte numeric tags. However, because google.protobuf.Struct is unstructured, it must serialize the actual field name string "age" on the wire. This completely forfeits the field-name compression benefit that makes static Protobuf so compact.

  3. No Varint Compression for Numbers: Instead of benefiting from Protobuf’s specialized integer encodings, all numeric values are represented through a double-precision float field. Small integers suffer most because Protobuf normally compresses them using varints. Large values may see less dramatic differences because JSON must serialize every digit as text. To visualize the wire layout comparison for representing the small number 30:

Compact JSON ("30"):
00110011 00110000 -> 2 bytes

Static Protobuf (Varint 30):
00011110 -> 1 byte

Dynamic Protobuf (30.0 double-precision float):
00000000 00000000 00000000 00000000 
00000000 00000000 00111110 01000000 -> 8 bytes (little-endian)

As a result of this deeply nested structure and fixed-size floats, dynamic Protobuf payloads often end up larger on the wire than compact JSON. However, this result is heavily workload-dependent. The biggest penalties come from having many fields, many keys, small numeric values, and deeply nested structures. A payload consisting mostly of a single massive string will not exhibit the same structural overhead.

Wire Inefficiency vs. Runtime Inefficiency

Dynamic Protobuf hurts in two completely different ways that affect different engineering decisions:

  1. Wire Inefficiency: The serialized payload becomes larger than many developers expect. This is caused by human-readable field names being serialized repeatedly, nested map entry encoding, double-precision float storage for all numbers, and the loss of varint integer compression. Bandwidth-sensitive systems, databases, or event brokers care heavily about this.
  2. Runtime Inefficiency: The runtime representation in Go becomes allocation-heavy and expensive to parse. This is caused by Go’s allocation behavior, interface-heavy and pointer-heavy structures in the standard structpb package, Go’s reflection model, and tree-shaped decoding. CPU-bound services that deserialize payloads frequently care heavily about this.

To see exactly how these wire and runtime inefficiencies compound, I built a benchmark suite to measure the real-world performance differences.

The Benchmark Setup

I built a Go benchmark comparing standard JSON against various Protobuf strategies across three payload sizes. The complete benchmark suite and Go test code are available in the sudorandom/kmcd.dev repository on GitHub.

The payload configurations are:

  • Small: A flat object with 4 fields (string ID, status boolean, age integer, score float).
  • Medium: A nested user signup event containing an actor object, string tags, and a metadata map.
  • Large: An array repeating the Medium object 100 times.

This benchmark focuses specifically on the tradeoff between schema-less Protobuf WKTs and common Go JSON implementations rather than surveying all binary serialization formats (like MessagePack, CBOR, BSON, Avro, or FlatBuffers).

Benchmark Variants

To evaluate performance across different serialization models, I compared the following variants:

VariantFormatDescription
Concrete (JSON)JSONSerializes a standard concrete Go struct using Go’s standard encoding/json library (JSON v1).
Concrete (JSONv2)JSONSerializes a standard concrete Go struct using the experimental, higher-performance github.com/go-json-experiment/json library (JSON v2).
Map (JSON)JSONSerializes a generic, schema-less Go map (map[string]any) using the standard encoding/json library.
Map (JSONv2)JSONSerializes a generic, schema-less Go map (map[string]any) using the experimental github.com/go-json-experiment/json library.
Concrete (proto)ProtobufSerializes statically generated Protobuf messages using Go’s official google.golang.org/protobuf/proto library.
Concrete (vtproto)ProtobufSerializes statically generated Protobuf messages using PlanetScale’s optimized, reflection-free vtproto generator.
google.protobuf.Any (proto)ProtobufSerializes static Protobuf messages wrapped in a dynamic, polymorphic Well-Known Type google.protobuf.Any.
google.protobuf.Value (proto)ProtobufSerializes schema-less dynamic payloads using the Well-Known Type google.protobuf.Value (structpb).
Protobuf + JSONProtobufBypasses the dynamic WKT wrapper by storing the raw serialized JSON string directly inside an opaque Protobuf string/bytes field (Opaque JSON Packaging).
Concrete (JSONProto)JSONSerializes statically generated Protobuf messages into JSON format using Go’s official protojson encoder.
google.protobuf.Value (JSONProto)JSONSerializes dynamic google.protobuf.Value payloads into JSON format using Go’s official protojson encoder.
google.protobuf.Any (JSONProto)JSONSerializes polymorphic google.protobuf.Any wrappers into JSON format using Go’s official protojson encoder.

A Note on Any vs Value: Any and Value solve fundamentally different problems. Any is not a schema-less alternative; it is a schema-dispatch mechanism. Any assumes a schema exists and the consumer knows it, while Value assumes no schema exists at all. The comparison is useful because teams often reach for Value when their actual requirement is polymorphism rather than truly schema-less data.

A Note on Payload Decoding in Benchmarks: To ensure a fair, apples-to-apples performance comparison, the unmarshaling benchmarks for all variants fully deserialize both the outer envelope and the inner dynamic/polymorphic payloads:

  • In the google.protobuf.Any benchmark, the payload is not left as raw bytes; it is fully unpacked into a concrete statically-compiled Go struct using anypb.UnmarshalTo().
  • In the Protobuf + JSON benchmark, the inner JSON string is not left unparsed; it is fully deserialized into a concrete Go struct using standard json.Unmarshal().
  • In the google.protobuf.Value benchmark, the payload is fully parsed into a tree of Go objects representing the JSON-like data.

Importantly, in real-world applications, both google.protobuf.Any and opaque Protobuf + JSON packaging allow you to bypass this inner parsing entirely on intermediate routing nodes (deferred/lazy parsing). This is a significant architectural advantage if intermediate services only need to forward or store the payload without inspecting it. However, to maintain a level playing field and measure the actual parsing cost, these benchmarks force full decoding.

To handle arbitrary data, the dynamic Protobuf configurations rely on standard structpb definitions:

syntax = "proto3";
package event;

import "google/protobuf/struct.proto";

message EventEnvelope {
  string id = 1;
  int64 timestamp = 2;
  google.protobuf.Value payload = 3; // Dynamic payload field
}

Benchmark Disclaimer and Workload Caveats

As with all performance testing, microbenchmarks should be taken with a grain of salt. Actual performance will vary depending on your specific hardware, operating system, compiler version, garbage collection settings, and payload structure. Workload shape and configuration matter enormously. Map-heavy workloads are particularly pathological for google.protobuf.Struct due to Go’s map lookup and insertion overhead. Deeply nested objects increase allocation and traversal costs substantially, whereas flat structures suffer less. Furthermore, implementation details like hot-path struct reuse or memory pooling (such as a thread-local arena pool solution using dynamic parser bytecode) can dramatically change outcomes in production, shifting the bottleneck back toward wire serialization and parsing logic.

Microbenchmarks represent synthetic workloads and may not perfectly translate to the performance profile of a complex, production system. You should always run benchmarks under your own representative workloads before making significant architectural decisions.

This article focuses specifically on Go’s protobuf implementation and the structpb runtime model. Other languages may exhibit different allocation and parsing characteristics, though the wire-format overhead discussed here remains universal.

Additionally, dynamic Protobuf is not always a poor choice. For admin panels, configuration APIs, low-volume integrations, or systems where schema flexibility matters more than throughput, google.protobuf.Value remains a perfectly reasonable choice. It is only when these structures sit directly on high-throughput hot paths that performance problems emerge.


Benchmark Results

The benchmarks were executed under Go 1.26 on an Apple M1 Pro.

Wire Size

First, I compared the serialized payload size of each configuration. Because Protobuf is famous for being compact, developers often assume even unstructured dynamic payloads using google.protobuf.Value will be smaller than standard JSON. Measuring serialized byte sizes is a straightforward test that yields definitive, objective results.

Note: These measurements reflect raw serialized payload size before transport-level compression. Systems using gzip or zstd may observe different relative wire sizes. Repeated JSON keys compress extremely well, though Protobuf map entries and repeated keys also benefit heavily from transport compression.

Show data table
Format / Config (Medium Payload)Serialized Size% of JSON (lower is better)
Concrete (JSON)291 B100.0% (Baseline)
Concrete (proto) / Concrete (vtproto)162 B55.7%
google.protobuf.Any (proto)212 B72.9%
Protobuf + JSON294 B101.0%
google.protobuf.Value (proto)328 B112.7%
Concrete (JSONProto)293 B100.7%
google.protobuf.Value (JSONProto)291 B100.0%
google.protobuf.Any (JSONProto)349 B119.9%

Processing Throughput

Wire size is only half the battle; parsing these dynamic structures introduces significant CPU and memory costs. Building and parsing schema-less Protobuf trees involves significant pointer-wrapping overhead, resulting in higher CPU usage and frequent heap allocations. Standard concrete Protobuf marshals almost instantly, and PlanetScale’s reflection-free generator Concrete (vtproto) is the absolute fastest. Much of vtproto’s advantage comes from eliminating reflection and generating specialized straight-line serialization code ahead of time.

Show data table
Benchmark (Medium Payload)ns/opMemory (B/op)Allocations/op
Concrete (vtproto)123 ns176 B1
google.protobuf.Any (proto)127 ns224 B1
Concrete (proto)356 ns176 B1
Concrete (JSON)634 ns464 B2
Protobuf + JSON844 ns1,024 B4
Concrete (JSONv2)985 ns608 B3
Map (JSONv2)1,835 ns456 B12
Map (JSON)2,346 ns1,200 B28
Concrete (JSONProto)2,797 ns1,722 B34
google.protobuf.Value (proto)4,872 ns736 B25
google.protobuf.Value (JSONProto)7,828 ns2,750 B70

The most surprising finding here is not that Value is slower than static Protobuf. Everyone expects that. The headline-worthy result is this: in these benchmarks, dynamic Protobuf frequently loses to plain JSON as well.

For a medium payload, standard static Protobuf is 13x faster than dynamic binary Value serialization, but standard JSON is over 7x faster than Value. When evaluating unmarshaling, the gap widens further:

Show data table
Benchmark (Medium Payload)ns/opMemory (B/op)Allocations/op
Concrete (vtproto)383 ns432 B14
Concrete (proto)690 ns560 B15
google.protobuf.Any (proto)906 ns864 B18
Concrete (JSONv2)1,410 ns256 B4
Map (JSONv2)2,595 ns1,392 B30
Concrete (JSON)3,659 ns688 B19
Protobuf + JSON3,970 ns1,392 B22
Map (JSON)4,177 ns1,856 B54
Concrete (JSONProto)4,659 ns1,304 B58
google.protobuf.Value (proto)5,686 ns2,888 B90
google.protobuf.Value (JSONProto)10,819 ns4,080 B145

Dynamic binary parsing takes 5,772 ns and requires 90 allocations, compared to just 674 ns and 15 allocations for standard static Protobuf.

Appendix: The Hidden Cost of Construction

To keep comparisons apples-to-apples, the main benchmarks above isolate the pure marshaling (serialization) and unmarshaling (deserialization) phases using pre-constructed message structures.

However, in real-world systems, using dynamic types introduces a secondary runtime overhead: translating native Go data structures (like map[string]any) into structpb.Value (construction) before serialization, and converting them back (using .AsInterface()) after deserialization.

The table below shows the cost of these construction and conversion steps across our three payload sizes:

PayloadPhaseTime (ns/op)Memory (B/op)Allocations/op
SmallConstruction (NewValue)504 ns671 B13
Conversion (AsInterface)300 ns368 B5
MediumConstruction (NewValue)2,076 ns2,223 B43
Conversion (AsInterface)1,127 ns1,240 B19
LargeConstruction (NewValue)200,092 ns223,407 B4,305
Conversion (AsInterface)129,140 ns125,818 B1,902

For a medium payload, constructing the structpb.Value and converting it back adds an extra 3.2 microseconds and 62 heap allocations to the overall lifecycle. When combined with the marshaling and unmarshaling costs, these conversion steps represent a significant hidden performance penalty in high-throughput Go services.

The Root Cause: Allocations and Pointer Chasing

With the wire and processing numbers in hand, we can look at the mechanical reasons behind the overhead.

  1. Go Runtime Allocations: Because Go is statically typed, representing a polymorphic JSON-like tree requires nesting interfaces and pointers. Deserializing a dynamic Value payload requires Go’s runtime to allocate a unique *structpb.Value pointer for every single map key, list item, and value in the tree. Every node in the tree is represented as a separate Protobuf message (Value, Struct, or ListValue), requiring recursive traversal during serialization and deserialization. On a large payload, this creates over 9,000 individual heap allocations, putting immense pressure on Go’s garbage collector and memory allocator. Furthermore, structpb relies heavily on interface boxing. Pushing values through these abstraction layers prevents the compiler from applying the aggressive optimizations it normally uses for generated static structs.
  2. Cache Locality and Memory Layout: At the systems level, statically generated Protobuf messages compile to flat, cache-friendly Go structs representing contiguous (or near-contiguous) memory blocks. In contrast, structpb.Value constructs a highly fragmented graph of heap-allocated objects connected by pointers. Traversing this tree-shaped structure causes frequent pointer chasing, which hurts cache locality, increases L1/L2 cache misses, and hinders branch prediction. Because the pointer graph fragments memory, the CPU prefetcher cannot effectively predict the next memory address, leading to those cache misses.

Are You Actually Solving a Dynamic Data Problem?

In practice, many google.protobuf.Value fields are not truly dynamic. They are often legacy JSON blobs carried forward during API migrations. Before reaching for Value, ask whether the payload has a finite, documented structure. If it does, a normal Protobuf message is usually the better long-term design. A dynamic field is often a temporary migration artifact rather than a genuine domain requirement. If the field’s shape is stable enough to document, it is usually stable enough to model as a statically typed Protobuf message.

High-Performance Alternatives

Importantly, this does not mean runtime Protobuf parsing itself is inherently slow. Rather, the real bottleneck is schema-less, JSON-style polymorphism layered onto Protobuf through Struct and Value.

If your system requires runtime schema flexibility, avoid google.protobuf.Struct for high-throughput paths and leverage these specific optimizations depending on your runtime requirements:

Polymorphism: Use google.protobuf.Any

When data conforms to a known set of pre-compiled schemas, wrap the fields in an Any message. It records a clean type_url string alongside raw compiled binary bytes.

  • Pros: Highly compact (212 bytes for a medium payload) and fast. Marshaling is over 35x faster than using generic values.
  • Cons: Requires compile-time schema awareness for all incoming types. Crucially, the consuming service must have the exact generated Go types compiled and registered in its binary to cleanly unpack the message (e.g., via anypb.UnmarshalTo()). If the global protobuf registry lacks the specific type matching the incoming type_url, unmarshaling will fail. This strict coupling highlights why Any is a schema-dispatch mechanism, not a drop-in replacement for fully unstructured JSON. Additionally, Any carries the wire overhead of serializing the type_url string (e.g., type.googleapis.com/package.Message), which adds a few dozen bytes depending on your package name length. This explains the size increases for Any visible in the benchmark charts compared to native static Protobuf.

Recommendations

To help choose the right design pattern for your dynamic payloads, you can follow this decision flow:

D2 Diagram

Model your actual data in Protobuf

Statically defining your schemas is always the ideal path. It yields the best performance, full compile-time type safety, and clear API contracts. Commit to first-class, statically typed fields whenever possible. It’s worth it.

Use a native map<string, string> for flat attributes

If your metadata is strictly flat key-value strings (like HTTP headers or tags), use a native map<string, string>. It converts cleanly to a native Go map without pointer wrapping or parsing overhead.

Use google.protobuf.Any for middle-layer opacity

If you have opaque data that you don’t want intermediate routing nodes to parse, wrap the payload in a google.protobuf.Any message. This allows middle layers to forward or store packets without deserialization, while downstream consumers can decode the payload cleanly using pre-compiled schemas.

Pack raw JSON into strings (Opaque JSON Packaging)

If you must support unstructured data on a high-throughput, latency-sensitive hot path, storing the dynamic data as raw JSON directly inside a standard string or bytes Protobuf field is often the best choice.

Let’s be honest: this feels gross. Wrapping raw, stringified JSON inside a Protobuf binary envelope violates schema purity, makes API documentation messy, and is generally a dirty hack. Specifically, it breaks schema-first workflows: API tooling (like OpenAPI generators, Buf, or gRPC gateways) that relies on strict contracts cannot introspect the structure of the JSON payload. This forces downstream consumers to rely on out-of-band documentation rather than compile-time safety and automatic code generation, making it a last-resort optimization.

But pragmatism sometimes has to beat purity in high-throughput systems. If you are on a high-throughput, latency-sensitive hot path, the numbers don’t care about architectural aesthetics. Bypassing dynamic WKT parsing in favor of opaque JSON packaging saves a lot of CPU cycles and heap allocations. Among the approaches evaluated here, opaque JSON packaging provides the best performance/flexibility tradeoff for fully unstructured payloads. You may not like it, but this might be what peak performance looks like:

message EventEnvelope {
  string event_json = 1;
  int64 timestamp = 2;
}

Use google.protobuf.Value for low-throughput dynamic data

If you just want a quick, standardized way to represent arbitrary JSON-like structures in Protobuf and your throughput/latency budgets aren’t tight, using the built-in Well-Known Types is completely fine and requires the least custom logic.

Conclusion

Static Protobuf delivers its benefits because the schema is known ahead of time. google.protobuf.Value intentionally gives up that information in exchange for flexibility. In Go, that tradeoff can be surprisingly expensive in both wire size and runtime cost. If your payload has a schema, model it. If it doesn’t and performance matters, opaque JSON may outperform dynamic Protobuf despite being less elegant.