Data Serialization Format Comparison

Philip Rehberger Feb 7, 2026 12 min read

Choose the right serialization format. Compare JSON, Protocol Buffers, Avro, and MessagePack for different use cases.

Data Serialization Format Comparison

Choosing the right data serialization format affects performance, storage costs, and developer experience. Different formats excel in different scenarios. Here's a comparison to help you choose wisely.

Format Overview

Comparison Matrix

Before diving into each format, this comparison matrix provides a quick reference for choosing based on your primary requirements. Consider human readability for debugging and configuration files, schema requirements for type safety, and size/speed for high-throughput systems.

Format     | Human     | Schema  | Size    | Speed   | Use Case
           | Readable  |         |         |         |
-----------+-----------+---------+---------+---------+------------------
JSON       | Yes       | Optional| Medium  | Medium  | APIs, configs
YAML       | Yes       | Optional| Medium  | Slow    | Configs, k8s
XML        | Yes       | Optional| Large   | Slow    | Enterprise, SOAP
Protobuf   | No        | Required| Small   | Fast    | Microservices
Avro       | No        | Required| Small   | Fast    | Big data, Kafka
MessagePack| No        | Optional| Small   | Fast    | Cache, sockets
CBOR       | No        | Optional| Small   | Fast    | IoT, constrained
Parquet    | No        | Required| Small   | Fast    | Analytics, data lake

The trade-offs between human readability and performance are fundamental. Development-time convenience often favors readable formats, while production performance favors binary formats.

JSON

Characteristics

JSON has become the lingua franca of web APIs due to its simplicity and universal support. Its self-describing nature means you can understand the data structure without external documentation, making it excellent for debugging and exploration.

This example shows a typical JSON payload with various data types including nested objects, arrays, and null values.

{
  "user": {
    "id": 12345,
    "name": "John Doe",
    "email": "john@example.com",
    "roles": ["admin", "user"],
    "active": true,
    "metadata": null
  }
}

Pros and Cons

Understanding JSON's limitations helps you decide when to use alternatives. The lack of binary and date types requires workarounds that add overhead and potential for inconsistency.

Pros:
+ Human readable/writable
+ Universal support (every language)
+ No schema required
+ Easy debugging
+ Browser native (JavaScript)

Cons:
- No native binary type (base64 encoding)
- No date type (string representation)
- Verbose (field names repeated)
- No comments allowed
- Slower parsing than binary formats

Best Practices

When working with JSON in production, optimize encoding settings and consider streaming for large datasets. The following PHP examples show common optimizations for reducing payload size and handling large data efficiently.

For small to medium payloads, encoding options can reduce size. For large datasets, streaming prevents memory exhaustion by processing records incrementally.

// Efficient JSON encoding
$data = ['users' => $users, 'total' => count($users)];

// Default
$json = json_encode($data);

// Compact (no pretty printing)
$json = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

// With streaming for large data
$stream = fopen('php://output', 'w');
fwrite($stream, '[');
$first = true;
foreach ($users as $user) {
    if (!$first) fwrite($stream, ',');
    fwrite($stream, json_encode($user));
    $first = false;
}
fwrite($stream, ']');

The streaming approach processes records one at a time, avoiding memory exhaustion when encoding millions of records. This is essential for export functionality and bulk data operations.

Protocol Buffers

Schema Definition

Protocol Buffers (Protobuf) use a strongly-typed schema that defines message structure. The schema serves as documentation and enables code generation for type-safe serialization in any supported language.

The field numbers in a Protobuf schema are crucial for wire format compatibility. They identify fields in the binary encoding and must remain stable across schema versions.

// user.proto
syntax = "proto3";

package myapp;

message User {
  int64 id = 1;
  string name = 2;
  string email = 3;
  repeated string roles = 4;
  bool active = 5;
  optional string metadata = 6;
  google.protobuf.Timestamp created_at = 7;
}

message UserList {
  repeated User users = 1;
  int32 total = 2;
}

The field numbers are crucial for schema evolution. They identify fields in the binary format, allowing you to add new fields without breaking existing consumers.

Usage

After defining your schema, use the Protocol Buffers compiler to generate language-specific classes. These generated classes provide type-safe methods for building and parsing messages.

This example shows the workflow from generating code to serializing and deserializing data. The size comparison demonstrates why Protobuf is preferred for high-throughput systems.

// Generate classes: protoc --php_out=. user.proto

use Myapp\User;
use Myapp\UserList;

// Serialize
$user = new User();
$user->setId(12345);
$user->setName('John Doe');
$user->setEmail('john@example.com');
$user->setRoles(['admin', 'user']);
$user->setActive(true);

$binary = $user->serializeToString();

// Deserialize
$decoded = new User();
$decoded->mergeFromString($binary);
echo $decoded->getName(); // "John Doe"

// Size comparison for same data:
// JSON: 156 bytes
// Protobuf: 52 bytes (67% smaller)

The 67% size reduction compounds across millions of messages. For high-throughput systems processing billions of events, this translates to significant bandwidth and storage savings.

Schema Evolution

Protobuf supports safe schema evolution as long as you follow certain rules. New fields can be added, and old clients will simply ignore them, maintaining backward compatibility.

These evolution rules ensure that old clients can read new data and new clients can read old data. The key is never reusing field numbers and always providing defaults for new fields.

// v1
message User {
  int64 id = 1;
  string name = 2;
}

// v2 - Adding fields (backward compatible)
message User {
  int64 id = 1;
  string name = 2;
  string email = 3;        // New field - old clients ignore
  optional int32 age = 4;  // Optional field
}

// Rules:
// - Never reuse field numbers
// - Use optional for nullable fields
// - Add new fields with new numbers
// - Deprecated fields: keep number reserved

Reserve field numbers of deleted fields to prevent accidental reuse. This protects against subtle bugs when different versions of your schema interpret the same field number differently.

Apache Avro

Schema

Avro uses JSON-based schemas that are stored with the data, making it self-describing while still being compact. This is particularly valuable in data processing pipelines where data may be read years after it was written.

The schema is verbose but human-readable, and it's stored alongside the data in Avro files, ensuring you can always decode the data even without access to the original schema definition.

{
  "type": "record",
  "name": "User",
  "namespace": "com.myapp",
  "fields": [
    {"name": "id", "type": "long"},
    {"name": "name", "type": "string"},
    {"name": "email", "type": "string"},
    {"name": "roles", "type": {"type": "array", "items": "string"}},
    {"name": "active", "type": "boolean"},
    {"name": "metadata", "type": ["null", "string"], "default": null}
  ]
}

Kafka Integration

Avro shines when combined with a Schema Registry for managing schema evolution in event-driven architectures. The registry ensures producers and consumers agree on message format while allowing controlled evolution.

This Python example shows the typical Avro-Kafka integration pattern. The Schema Registry validates schema compatibility before accepting new versions.

from confluent_kafka import Producer
from confluent_kafka.schema_registry import SchemaRegistryClient
from confluent_kafka.schema_registry.avro import AvroSerializer

# Schema Registry for schema management
schema_registry = SchemaRegistryClient({'url': 'http://schema-registry:8081'})

# Avro serializer with schema evolution
avro_serializer = AvroSerializer(
    schema_registry,
    schema_str,
    to_dict=lambda user, ctx: user.__dict__
)

# Produce message
producer = Producer({'bootstrap.servers': 'kafka:9092'})
producer.produce(
    topic='users',
    key=str(user.id),
    value=avro_serializer(user, SerializationContext('users', MessageField.VALUE))
)

The Schema Registry validates schema compatibility before allowing changes, preventing accidental breaking changes that would cause consumer failures.

Schema Evolution

Avro provides explicit compatibility modes that control how schemas can evolve. Understanding these modes helps you design schemas that can grow with your system without breaking existing data or consumers.

When adding new fields to an Avro schema, you must provide default values for backward compatibility. This allows old records without the new field to be read by the new schema.

// v1 schema
{
  "type": "record",
  "name": "User",
  "fields": [
    {"name": "id", "type": "long"},
    {"name": "name", "type": "string"}
  ]
}

// v2 schema - backward compatible
{
  "type": "record",
  "name": "User",
  "fields": [
    {"name": "id", "type": "long"},
    {"name": "name", "type": "string"},
    {"name": "email", "type": "string", "default": ""}  // Default required!
  ]
}

// Compatibility modes:
// BACKWARD: New schema can read old data
// FORWARD: Old schema can read new data
// FULL: Both directions

The default value is essential for backward compatibility. Without it, old records that lack the email field cannot be read by the new schema.

MessagePack

Usage

MessagePack provides a binary alternative to JSON that's faster and smaller while maintaining JSON's schemaless flexibility. It's excellent for caching and inter-process communication where you want JSON's convenience with better performance.

The API is nearly identical to JSON, making it easy to adopt. The size and speed improvements shown here are typical for structured data.

// Very similar to JSON but binary

// Encode
$data = [
    'id' => 12345,
    'name' => 'John Doe',
    'roles' => ['admin', 'user'],
];
$packed = msgpack_pack($data);

// Decode
$unpacked = msgpack_unpack($packed);

// Size comparison:
// JSON: 68 bytes
// MessagePack: 45 bytes (34% smaller)

// Speed comparison (1M iterations):
// JSON encode: 2.1 seconds
// MessagePack: 0.8 seconds (2.6x faster)

MessagePack preserves numeric types exactly, unlike JSON which converts everything to IEEE 754 floating point. This matters when you need precise large integers or want to distinguish between integers and floats.

Redis Caching

MessagePack's compact size and fast serialization make it ideal for caching. The smaller payloads reduce Redis memory usage, and the faster serialization reduces CPU time for cache operations.

This cache wrapper demonstrates the pattern for using MessagePack with Redis. The implementation is straightforward and provides immediate benefits for memory and CPU usage.

class MessagePackCache
{
    public function set(string $key, mixed $value, int $ttl = 3600): void
    {
        $packed = msgpack_pack($value);
        $this->redis->setex($key, $ttl, $packed);
    }

    public function get(string $key): mixed
    {
        $packed = $this->redis->get($key);
        if ($packed === null) {
            return null;
        }
        return msgpack_unpack($packed);
    }
}

// Benefits for caching:
// - Smaller memory footprint
// - Faster serialization
// - Preserves types (unlike JSON numbers)

The memory savings compound when caching large datasets. A 34% reduction in cached object size means your Redis instance can store 50% more objects before needing to evict or upgrade.

Parquet (Columnar)

For Analytics

Parquet stores data in columnar format, which is fundamentally different from row-based formats like JSON or Protobuf. This columnar layout provides dramatic performance improvements for analytical queries that access only a subset of columns.

When reading Parquet files, you can specify which columns to load, avoiding I/O for columns you don't need. Predicate pushdown further optimizes by skipping row groups that don't match your filter.

import pyarrow as pa
import pyarrow.parquet as pq

# Create table
data = {
    'id': [1, 2, 3, 4, 5],
    'name': ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'],
    'amount': [100.5, 200.0, 150.75, 300.25, 50.0],
    'date': ['2024-01-01', '2024-01-02', '2024-01-03', '2024-01-04', '2024-01-05']
}
table = pa.Table.from_pydict(data)

# Write to Parquet
pq.write_table(table, 'data.parquet', compression='snappy')

# Read specific columns only (columnar benefit)
table = pq.read_table('data.parquet', columns=['id', 'amount'])

# Predicate pushdown
table = pq.read_table(
    'data.parquet',
    filters=[('amount', '>', 100)]
)

Predicate pushdown allows Parquet readers to skip entire row groups that don't match your filter criteria, reading only the data that could possibly satisfy your query.

Why Columnar for Analytics

The columnar format fundamentally changes I/O patterns for analytical queries. Instead of reading entire rows to access a single column, you read only the columns you need.

This comparison illustrates why columnar formats dominate in analytics. An aggregation query over one column reads only that column's data, regardless of how many other columns exist.

Row format (JSON, Avro):
Row 1: [id:1, name:Alice, amount:100.5, date:2024-01-01]
Row 2: [id:2, name:Bob,   amount:200.0, date:2024-01-02]

Query: SELECT SUM(amount) FROM users
Reads: All columns for all rows

Columnar format (Parquet):
Column 'id':     [1, 2, 3, 4, 5]
Column 'name':   [Alice, Bob, Charlie, Diana, Eve]
Column 'amount': [100.5, 200.0, 150.75, 300.25, 50.0]
Column 'date':   [2024-01-01, ...]

Query: SELECT SUM(amount) FROM users
Reads: Only 'amount' column

Benefits:
- Much less I/O for analytics queries
- Better compression (similar values together)
- Vectorized processing

Columnar compression works exceptionally well because similar values are stored together. A column of country codes compresses much better than countries scattered across rows.

Choosing the Right Format

Decision Guide

This decision tree helps you select the appropriate format based on your specific use case. Start with your primary requirement and follow the branches to find the best match.

API responses:
├── External API → JSON (universal)
├── Internal microservices → Protobuf (performance)
└── GraphQL → JSON (native)

Configuration:
├── Human-edited → YAML or JSON
├── Machine-generated → JSON
└── Complex/nested → YAML

Caching:
├── Simple values → Native (string, int)
├── Complex objects → MessagePack
└── Large datasets → Compressed JSON

Message Queues:
├── Schema-enforced → Avro with Schema Registry
├── High throughput → Protobuf
└── Flexible → JSON

Data Lake/Analytics:
├── Columnar queries → Parquet
├── Streaming → Avro
└── Log files → JSON Lines (JSONL)

Performance Benchmarks

These benchmarks demonstrate the performance differences between formats for typical workloads. Your actual results will vary based on data characteristics and hardware.

Serialization speed (1M objects):
MessagePack: 0.8s
Protobuf:    1.2s
JSON:        2.1s
YAML:        15.0s

Deserialization speed (1M objects):
Protobuf:    0.9s
MessagePack: 1.1s
JSON:        3.5s
YAML:        20.0s

Size (same data):
Protobuf:    52 bytes (baseline)
MessagePack: 65 bytes (+25%)
JSON:        156 bytes (+200%)
YAML:        180 bytes (+246%)

YAML's slow performance makes it unsuitable for high-throughput data processing, but its readability makes it excellent for configuration files that are parsed once at startup.

Hybrid Approaches

Real systems often use multiple formats at different boundaries. An API gateway might accept JSON from external clients, convert to Protobuf for internal communication, and use MessagePack for caching. This combines the benefits of each format where they matter most.

This example shows format translation at system boundaries. External clients use familiar JSON while internal services benefit from binary formats. The cache uses MessagePack for efficiency.

// API Gateway pattern
class ApiGateway
{
    public function handleRequest(Request $request): Response
    {
        // Accept JSON from external clients
        $data = json_decode($request->getContent(), true);

        // Convert to Protobuf for internal services
        $proto = $this->toProtobuf($data);
        $response = $this->internalService->call($proto);

        // Convert back to JSON for response
        return response()->json(
            $this->fromProtobuf($response)
        );
    }
}

// Cache with MessagePack, respond with JSON
class UserService
{
    public function getUser(int $id): array
    {
        // Try MessagePack cache
        $cached = $this->cache->get("user:{$id}");
        if ($cached) {
            return msgpack_unpack($cached);
        }

        $user = $this->repository->find($id);
        $this->cache->set("user:{$id}", msgpack_pack($user));

        return $user;  // Returned as JSON via response
    }
}

The conversion overhead is usually negligible compared to network latency. The performance gains from using efficient internal formats typically far outweigh the cost of format conversion at boundaries.

Conclusion

JSON remains the default for APIs due to universal support and human readability. Use Protobuf or MessagePack when performance and size matter for internal services. Avro with Schema Registry works well for Kafka and data pipelines with schema evolution needs. Parquet excels for analytics workloads with columnar queries. Match the format to your use case rather than using one format everywhere. Consider hybrid approaches where external interfaces use JSON while internal communication uses binary formats.

Share this article

Related Articles

Need help with your project?

Let's discuss how we can help you build reliable software.